Skip to content

Commit df1705f

Browse files
committed
fix: preserve SSH URLs in getting-started-samples
Fix issue where SSH URLs ([email protected]:user/repo.git) in getting-started-samples were incorrectly converted to HTTPS URLs, causing project-clone failures. Changes: 1. Registry metadata processing (devfiles.ts) - updateObjectLinks function now properly detects and preserves SSH URLs using FactoryLocationAdapter.isSshLocation() check - SSH URLs are no longer treated as relative URLs 2. SamplesList component (SamplesList/index.tsx) - handleSampleCardClick now checks URL type before processing - SSH URLs are passed directly without URL() constructor parsing - HTTP(S) URLs continue to be parsed and encoded as before Added comprehensive test coverage: - Registry service: 6 tests for SSH URL handling in metadata - SamplesList component: 2 tests for SSH URL handling including revision parameter support Fixes: CRW-9671 Signed-off-by: Oleksii Orel <[email protected]>
1 parent 3ad6fa5 commit df1705f

File tree

4 files changed

+213
-3
lines changed

4 files changed

+213
-3
lines changed

packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__tests__/index.spec.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,96 @@ describe('Samples List', () => {
239239
);
240240
});
241241
});
242+
243+
describe('SSH URL handling', () => {
244+
const preferredPvcStrategy = 'per-workspace';
245+
246+
test('should handle SSH URL with .git extension', async () => {
247+
const sshUrl = '[email protected]:eclipse-che/che-dashboard.git';
248+
const store = new MockStoreBuilder()
249+
.withBranding({
250+
docs: {
251+
storageTypes: 'storage-types-docs',
252+
},
253+
} as BrandingData)
254+
.withDwServerConfig({
255+
defaults: {
256+
pvcStrategy: preferredPvcStrategy,
257+
} as api.IServerConfig['defaults'],
258+
})
259+
.withDevfileRegistries({
260+
registries: {
261+
['registry-url']: {
262+
metadata: [
263+
{
264+
displayName: 'Test Sample SSH',
265+
description: 'Test sample with SSH URL',
266+
tags: ['Test'],
267+
icon: '/images/test.svg',
268+
links: {
269+
v2: sshUrl,
270+
},
271+
},
272+
],
273+
},
274+
},
275+
})
276+
.build();
277+
278+
renderComponent(store, editorDefinition, editorImage);
279+
280+
const sampleCardButton = screen.getByRole('button', { name: 'Select Sample' });
281+
await userEvent.click(sampleCardButton);
282+
283+
expect(mockWindowOpen).toHaveBeenCalledWith(
284+
`${origin}/dashboard/#/load-factory?url=git%40github.com%3Aeclipse-che%2Fche-dashboard.git&che-editor=che-incubator%2Fche-code%2Finsiders&editor-image=custom-editor-image&storageType=${preferredPvcStrategy}`,
285+
'_blank',
286+
);
287+
});
288+
289+
test('should handle SSH URL with revision parameter', async () => {
290+
const sshUrl = '[email protected]:eclipse-che/che-dashboard.git?revision=test';
291+
const store = new MockStoreBuilder()
292+
.withBranding({
293+
docs: {
294+
storageTypes: 'storage-types-docs',
295+
},
296+
} as BrandingData)
297+
.withDwServerConfig({
298+
defaults: {
299+
pvcStrategy: preferredPvcStrategy,
300+
} as api.IServerConfig['defaults'],
301+
})
302+
.withDevfileRegistries({
303+
registries: {
304+
['registry-url']: {
305+
metadata: [
306+
{
307+
displayName: 'Test Sample SSH with Branch',
308+
description: 'Test sample with SSH URL and revision',
309+
tags: ['Test'],
310+
icon: '/images/test.svg',
311+
links: {
312+
v2: sshUrl,
313+
},
314+
},
315+
],
316+
},
317+
},
318+
})
319+
.build();
320+
321+
renderComponent(store, editorDefinition, editorImage);
322+
323+
const sampleCardButton = screen.getByRole('button', { name: 'Select Sample' });
324+
await userEvent.click(sampleCardButton);
325+
326+
expect(mockWindowOpen).toHaveBeenCalledWith(
327+
`${origin}/dashboard/#/load-factory?url=git%40github.com%3Aeclipse-che%2Fche-dashboard.git%3Frevision%3Dtest&che-editor=che-incubator%2Fche-code%2Finsiders&editor-image=custom-editor-image&storageType=${preferredPvcStrategy}`,
328+
'_blank',
329+
);
330+
});
331+
});
242332
});
243333

244334
function getComponent(

packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { connect, ConnectedProps } from 'react-redux';
2525
import SamplesListGallery from '@/pages/GetStarted/SamplesList/Gallery';
2626
import SamplesListToolbar from '@/pages/GetStarted/SamplesList/Toolbar';
2727
import { ROUTE } from '@/Routes';
28+
import { FactoryLocationAdapter } from '@/services/factory-location-adapter';
2829
import {
2930
DEV_WORKSPACE_ATTR,
3031
EDITOR_ATTR,
@@ -97,9 +98,20 @@ class SamplesList extends React.PureComponent<Props, State> {
9798

9899
private async handleSampleCardClick(metadata: DevfileRegistryMetadata): Promise<void> {
99100
const { editorDefinition, editorImage } = this.props;
100-
const url = new URL(metadata.links.v2);
101+
102+
// Handle SSH URLs (git@...) and HTTP(S) URLs differently
103+
let factoryUrl: string;
104+
if (FactoryLocationAdapter.isSshLocation(metadata.links.v2)) {
105+
// SSH URLs should be used as-is
106+
factoryUrl = metadata.links.v2;
107+
} else {
108+
// HTTP(S) URLs need to be parsed and encoded properly
109+
const url = new URL(metadata.links.v2);
110+
factoryUrl = `${url.origin}${url.pathname}${encodeURIComponent(url.search)}`;
111+
}
112+
101113
const factoryParams: { [key: string]: string } = {
102-
[FACTORY_URL_ATTR]: `${url.origin}${url.pathname}${encodeURIComponent(url.search)}`,
114+
[FACTORY_URL_ATTR]: factoryUrl,
103115
};
104116

105117
const _editorDefinition = editorDefinition || this.props.defaultEditorId;

packages/dashboard-frontend/src/services/registry/__tests__/devfiles.spec.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,88 @@ describe('fetch registry metadata', () => {
187187
},
188188
]);
189189
});
190+
191+
it('should fetch getting started sample with SSH URL', async () => {
192+
const metadata = {
193+
displayName: 'Test Sample',
194+
tags: [],
195+
url: '[email protected]:testuser/testdev.git',
196+
icon: { mediatype: 'image/png', base64data: 'some-data' },
197+
} as any as che.DevfileMetaData;
198+
mockFetchData.mockResolvedValue([metadata]);
199+
200+
const resolved = await fetchRegistryMetadata(
201+
`${baseUrl}/dashboard/api/getting-started-sample`,
202+
false,
203+
);
204+
205+
expect(mockFetchData).toHaveBeenCalledTimes(1);
206+
expect(mockFetchData).toHaveBeenCalledWith(
207+
`${baseUrl}/dashboard/api/getting-started-sample`,
208+
);
209+
expect(resolved).toEqual([
210+
{
211+
displayName: 'Test Sample',
212+
tags: [],
213+
links: {
214+
v2: '[email protected]:testuser/testdev.git',
215+
},
216+
icon: { mediatype: 'image/png', base64data: 'some-data' },
217+
},
218+
]);
219+
});
220+
221+
it('should fetch getting started sample with SSH URL without .git extension', async () => {
222+
const metadata = {
223+
displayName: 'Test Sample',
224+
tags: [],
225+
url: '[email protected]:namespace/project',
226+
icon: { mediatype: 'image/png', base64data: 'some-data' },
227+
} as any as che.DevfileMetaData;
228+
mockFetchData.mockResolvedValue([metadata]);
229+
230+
const resolved = await fetchRegistryMetadata(
231+
`${baseUrl}/dashboard/api/getting-started-sample`,
232+
false,
233+
);
234+
235+
expect(resolved).toEqual([
236+
{
237+
displayName: 'Test Sample',
238+
tags: [],
239+
links: {
240+
v2: '[email protected]:namespace/project',
241+
},
242+
icon: { mediatype: 'image/png', base64data: 'some-data' },
243+
},
244+
]);
245+
});
246+
247+
it('should fetch getting started sample with HTTPS URL', async () => {
248+
const metadata = {
249+
displayName: 'Test Sample HTTPS',
250+
tags: [],
251+
url: 'https://github.com/testuser/testdev.git',
252+
icon: { mediatype: 'image/png', base64data: 'some-data' },
253+
} as any as che.DevfileMetaData;
254+
mockFetchData.mockResolvedValue([metadata]);
255+
256+
const resolved = await fetchRegistryMetadata(
257+
`${baseUrl}/dashboard/api/getting-started-sample`,
258+
false,
259+
);
260+
261+
expect(resolved).toEqual([
262+
{
263+
displayName: 'Test Sample HTTPS',
264+
tags: [],
265+
links: {
266+
v2: 'https://github.com/testuser/testdev.git',
267+
},
268+
icon: { mediatype: 'image/png', base64data: 'some-data' },
269+
},
270+
]);
271+
});
190272
});
191273

192274
it('should throw an error if fetched data is not array', async () => {
@@ -613,6 +695,30 @@ describe('devfile links', () => {
613695
expect(updated).toBe('http://asbolute.link');
614696
});
615697

698+
it('should not update SSH URL with [email protected]', () => {
699+
const object = '[email protected]:testuser/testdev.git';
700+
const updated = updateObjectLinks(object, baseUrl);
701+
702+
// SSH URLs should remain unchanged
703+
expect(updated).toBe('[email protected]:testuser/testdev.git');
704+
});
705+
706+
it('should not update SSH URL with [email protected]', () => {
707+
const object = '[email protected]:namespace/project.git';
708+
const updated = updateObjectLinks(object, baseUrl);
709+
710+
// SSH URLs should remain unchanged
711+
expect(updated).toBe('[email protected]:namespace/project.git');
712+
});
713+
714+
it('should not update SSH URL without .git extension', () => {
715+
const object = '[email protected]:user/repo';
716+
const updated = updateObjectLinks(object, baseUrl);
717+
718+
// SSH URLs should remain unchanged
719+
expect(updated).toBe('[email protected]:user/repo');
720+
});
721+
616722
it('should update complex objects', () => {
617723
const object = {
618724
link1: '/devfile/foo.yaml',

packages/dashboard-frontend/src/services/registry/devfiles.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import common from '@eclipse-che/common';
1414

1515
import { getDataResolver } from '@/services/backend-client/dataResolverApi';
16+
import { FactoryLocationAdapter } from '@/services/factory-location-adapter';
1617
import { che } from '@/services/models';
1718
import { fetchData } from '@/services/registry/fetchData';
1819
import { isDevfileMetaData } from '@/services/registry/types';
@@ -107,7 +108,8 @@ export function resolveLinks(
107108

108109
export function updateObjectLinks(object: any, baseUrl): any {
109110
if (typeof object === 'string') {
110-
if (!object.startsWith('http')) {
111+
// Don't modify absolute URLs (http/https) or SSH URLs (git@...)
112+
if (!object.startsWith('http') && !FactoryLocationAdapter.isSshLocation(object)) {
111113
object = createURL(object, baseUrl).href;
112114
}
113115
} else {

0 commit comments

Comments
 (0)