Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions tests/e2e/specs/api/ApiUrlResolverAPI.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> } };
const workspacesUrl: string = 'dashboard/api/namespace/user/devworkspaces';

function createResolver(workspaces: DevWorkspaceItem[], directMatch: boolean = false): ApiUrlResolver {
const requestHandler: CheApiRequestHandler = {
get: (relativeUrl: string): Promise<AxiosResponse> => {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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'
);
});
});
23 changes: 13 additions & 10 deletions tests/e2e/utils/workspace/ApiUrlResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> } };

@injectable()
export class ApiUrlResolver {
private static readonly DASHBOARD_API_URL: string = 'dashboard/api/namespace';
Expand All @@ -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
Expand All @@ -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
Expand All @@ -66,20 +68,21 @@ 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;
}

// 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 + '-')) {
Expand All @@ -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;
}

Expand All @@ -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<string> {
Expand Down