diff --git a/tests/e2e/specs/api/ApiUrlResolverAPI.spec.ts b/tests/e2e/specs/api/ApiUrlResolverAPI.spec.ts new file mode 100644 index 00000000000..94fae10a459 --- /dev/null +++ b/tests/e2e/specs/api/ApiUrlResolverAPI.spec.ts @@ -0,0 +1,91 @@ +/** ******************************************************************* + * copyright (c) 2026 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 + **********************************************************************/ +import { AxiosResponse } from 'axios'; +import { expect } from 'chai'; +import { CheApiRequestHandler } from '../../utils/request-handlers/CheApiRequestHandler'; +import { ApiUrlResolver } from '../../utils/workspace/ApiUrlResolver'; + +suite('Workspace API URL resolver', function (): void { + type DevWorkspaceItem = { metadata: { name: string; labels?: Record } }; + const workspacesUrl: string = 'dashboard/api/namespace/user/devworkspaces'; + + function createResolver(workspaces: DevWorkspaceItem[], directMatch: boolean = false): ApiUrlResolver { + const requestHandler: CheApiRequestHandler = { + get: (relativeUrl: string): Promise => { + if (relativeUrl === 'api/kubernetes/namespace') { + return Promise.resolve({ status: 200, data: [{ name: 'user' }] } as AxiosResponse); + } + if (relativeUrl === `${workspacesUrl}/workspace`) { + if (directMatch) { + return Promise.resolve({ status: 200, data: {} } as AxiosResponse); + } + throw new Error('Workspace not found'); + } + if (relativeUrl === workspacesUrl) { + return Promise.resolve({ status: 200, data: { items: workspaces } } as AxiosResponse); + } + throw new Error(`Unexpected request: ${relativeUrl}`); + } + } as unknown as CheApiRequestHandler; + return new ApiUrlResolver(requestHandler); + } + + async function expectResolutionError(workspaces: DevWorkspaceItem[], expectedMessage: string): Promise { + let actualError: Error | undefined; + try { + await createResolver(workspaces).getWorkspaceApiUrl('workspace'); + } catch (error) { + actualError = error as Error; + } + expect(actualError?.message).to.contain(expectedMessage); + } + + test('resolves an exact DevWorkspace name directly', async function (): Promise { + const url: string = await createResolver([], true).getWorkspaceApiUrl('workspace'); + expect(url).to.equal(`${workspacesUrl}/workspace`); + }); + + test('resolves an exact DevWorkspace name from the list after a failed direct request', async function (): Promise { + const url: string = await createResolver([{ metadata: { name: 'workspace' } }]).getWorkspaceApiUrl('workspace'); + expect(url).to.equal(`${workspacesUrl}/workspace`); + }); + + test('resolves a restored DevWorkspace with a matching display-name label', async function (): Promise { + const workspaces: DevWorkspaceItem[] = [ + { metadata: { name: 'workspace-4fnq', labels: { 'kubernetes.io/metadata.name': 'workspace' } } } + ]; + const url: string = await createResolver(workspaces).getWorkspaceApiUrl('workspace'); + expect(url).to.equal(`${workspacesUrl}/workspace-4fnq`); + }); + + test('does not resolve a different workspace with a similar name', async function (): Promise { + await expectResolutionError( + [{ metadata: { name: 'workspace-2', labels: { 'kubernetes.io/metadata.name': 'workspace-2' } } }], + 'Workspace not found: workspace (tried exact match and display-name search)' + ); + }); + + test('does not resolve a suffixed workspace without a display-name label', async function (): Promise { + await expectResolutionError( + [{ metadata: { name: 'workspace-4fnq' } }], + 'Workspace not found: workspace (tried exact match and display-name search)' + ); + }); + + test('rejects multiple restored DevWorkspaces with the same display name', async function (): Promise { + await expectResolutionError( + [ + { metadata: { name: 'workspace-4fnq', labels: { 'kubernetes.io/metadata.name': 'workspace' } } }, + { metadata: { name: 'workspace-x7q2', labels: { 'kubernetes.io/metadata.name': 'workspace' } } } + ], + 'Multiple workspaces found matching' + ); + }); +}); diff --git a/tests/e2e/utils/workspace/ApiUrlResolver.ts b/tests/e2e/utils/workspace/ApiUrlResolver.ts index b10577e25f2..a613c4a0a1d 100644 --- a/tests/e2e/utils/workspace/ApiUrlResolver.ts +++ b/tests/e2e/utils/workspace/ApiUrlResolver.ts @@ -13,6 +13,8 @@ import { Logger } from '../Logger'; import { CheApiRequestHandler } from '../request-handlers/CheApiRequestHandler'; import { AxiosResponse } from 'axios'; +type DevWorkspaceItem = { metadata: { name: string; labels?: Record } }; + @injectable() export class ApiUrlResolver { private static readonly DASHBOARD_API_URL: string = 'dashboard/api/namespace'; @@ -38,7 +40,7 @@ export class ApiUrlResolver { /** * resolves the actual DevWorkspace name from the API. * If the exact workspace name exists, returns it as is. - * If not found, searches for a workspace whose name matches the pattern: workspaceName + '-' + random suffix + * If not found, searches for a workspace with a matching display-name label and a generated name * (to handle cases where DevWorkspace has a random suffix like '-4fnq' after backup/restore). * @param workspaceName - The workspace name to search for * @returns The actual DevWorkspace name from the API @@ -56,8 +58,8 @@ export class ApiUrlResolver { return workspaceName; } } catch (error) { - // workspace not found by exact name, will search by prefix with suffix pattern - Logger.debug(`Exact match not found for ${workspaceName}, searching by prefix with suffix`); + // workspace not found by exact name, will search by display name and generated suffix + Logger.debug(`Exact match not found for ${workspaceName}, searching by display name and suffix`); } // if exact match not found, get all workspaces and search in the list @@ -66,12 +68,10 @@ export class ApiUrlResolver { throw new Error(`Cannot get workspaces list. Code: ${allWorkspacesResponse.status} Data: ${allWorkspacesResponse.data}`); } - const workspaces: Array<{ metadata: { name: string } }> = allWorkspacesResponse.data.items || []; + const workspaces: DevWorkspaceItem[] = allWorkspacesResponse.data.items || []; // check for exact match in the list (handles transient failures from the direct GET above) - const exactMatchInList: { metadata: { name: string } } | undefined = workspaces.find( - (ws): boolean => ws.metadata.name === workspaceName - ); + const exactMatchInList: DevWorkspaceItem | undefined = workspaces.find((ws): boolean => ws.metadata.name === workspaceName); if (exactMatchInList) { Logger.debug(`Found exact match in workspace list: ${workspaceName}`); return workspaceName; @@ -79,7 +79,10 @@ export class ApiUrlResolver { // look for workspace with pattern: workspaceName + '-' + suffix (e.g., 'test-workspace-2-4fnq') // this ensures we don't match 'test-workspace-20' when looking for 'test-workspace-2' - const matchingWorkspaces: Array<{ metadata: { name: string } }> = workspaces.filter((ws): boolean => { + const matchingWorkspaces: DevWorkspaceItem[] = workspaces.filter((ws): boolean => { + if (ws.metadata.labels?.['kubernetes.io/metadata.name'] !== workspaceName) { + return false; + } const dwName: string = ws.metadata.name; // check if name starts with workspaceName followed by a dash if (dwName.startsWith(workspaceName + '-')) { @@ -91,7 +94,7 @@ export class ApiUrlResolver { }); if (matchingWorkspaces.length === 1) { - Logger.debug(`Found workspace by prefix: ${matchingWorkspaces[0].metadata.name} (requested: ${workspaceName})`); + Logger.debug(`Found workspace by display name: ${matchingWorkspaces[0].metadata.name} (requested: ${workspaceName})`); return matchingWorkspaces[0].metadata.name; } @@ -102,7 +105,7 @@ export class ApiUrlResolver { ); } - throw new Error(`Workspace not found: ${workspaceName} (tried exact match and prefix search)`); + throw new Error(`Workspace not found: ${workspaceName} (tried exact match and display-name search)`); } private async obtainUserNamespace(): Promise {