Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
saveConnectionReferences,
saveCustomCodeStandard,
} from '../../../../utils/codeless/connection';
import { getAuthorizationToken } from '../../../../utils/codeless/getAuthorizationToken';
import { saveWorkflowParameter } from '../../../../utils/codeless/parameter';
import { startDesignTimeApi } from '../../../../utils/codeless/startDesignTimeApi';
import { sendRequest } from '../../../../utils/requestUtils';
Expand Down Expand Up @@ -58,6 +59,7 @@ export default class LocalDesignerPanel extends DesignerPanel {
private projectPath?: string;
private panelMetadata?: DesignerPanelMetadata;
private workflowRuntimeBaseUrlInterval?: NodeJS.Timeout;
private accessTokenInterval?: NodeJS.Timeout;

constructor(context: IActionContext, node: Uri, runId?: string) {
const workflowName = path.basename(path.dirname(node.fsPath));
Expand Down Expand Up @@ -178,6 +180,7 @@ export default class LocalDesignerPanel extends DesignerPanel {
this.panel.onDidDispose(
() => {
clearInterval(this.workflowRuntimeBaseUrlInterval);
clearInterval(this.accessTokenInterval);
removeWebviewPanelFromCache(this.panelGroupKey, this.panelName);
},
null,
Expand Down Expand Up @@ -207,6 +210,31 @@ export default class LocalDesignerPanel extends DesignerPanel {
}
}, 3000);

// Refresh access token every 30 minutes to prevent stale-token failures on save.
// Using a long interval because Azure tokens are valid for ~1 hour and this
// prevents flooding the auth service with unnecessary requests.
this.accessTokenInterval = setInterval(async () => {
try {
const tenantId = this.panelMetadata?.azureDetails?.tenantId;
const updatedAccessToken = await getAuthorizationToken(tenantId);
// Guard: skip if the token is empty/invalid to avoid overwriting a valid token
if (!updatedAccessToken || updatedAccessToken === this.panelMetadata?.accessToken) {
return;
}
if (this.panelMetadata) {
this.panelMetadata.accessToken = updatedAccessToken;
}
this.panel?.webview.postMessage({
command: ExtensionCommand.update_access_token,
data: {
accessToken: updatedAccessToken,
},
});
} catch {
// Silently ignore token refresh failures — the existing token may still be valid
}
}, 30 * 60 * 1000); // 30 minutes

this.panel?.webview.postMessage({
command: ExtensionCommand.initialize_frame,
data: {
Expand Down Expand Up @@ -603,6 +631,10 @@ export default class LocalDesignerPanel extends DesignerPanel {

/**
* Merges parameters from JSON.
* For parameters that exist only in the file (not in the designer output or panel),
* they are preserved as-is. For parameters that exist in both the file and designer,
* file-only properties (e.g., metadata, description) are preserved while designer
* properties take precedence.
* @param filePath The file path of the parameters JSON file.
* @param definitionParameters The parameters from the designer.
* @param panelParameterRecord The parameters from the panel
Expand All @@ -617,7 +649,17 @@ export default class LocalDesignerPanel extends DesignerPanel {

Object.entries(jsonParameters).forEach(([key, parameter]) => {
if (!definitionParameters[key] && !panelParameterRecord[key]) {
// Parameter exists only in the file — preserve it entirely
definitionParameters[key] = parameter;
} else if (definitionParameters[key]) {
// Parameter exists in both — preserve file-only properties that the designer doesn't emit
const fileParam = parameter as Record<string, any>;
const defParam = definitionParameters[key] as Record<string, any>;
for (const prop of Object.keys(fileParam)) {
if (!(prop in defParam)) {
defParam[prop] = fileParam[prop];
}
}
}
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getAuthorizationToken, getAuthorizationTokenFromNode, getCloudHost } from '../getAuthorizationToken';

// The module-level mock for '@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode'
// is aliased via vitest.config.ts to '__mocks__/vscode-azext-azureauth.ts'.
// We import and spy on it to control return values per test.
import * as azureAuth from '@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode';
import * as vscode from 'vscode';

vi.mock('@microsoft/vscode-azext-azureauth', () => ({
getConfiguredAzureEnv: vi.fn(() => ({
managementEndpointUrl: 'https://management.azure.com',
})),
}));

describe('getAuthorizationToken', () => {
beforeEach(() => {
vi.restoreAllMocks();
// Mock vscode.workspace.getConfiguration to return a config with get()
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
get: vi.fn(() => false),
} as any);
});

it('should return a Bearer token when session has an accessToken', async () => {
vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({
accessToken: 'test-token-123',
id: 'session-1',
account: { id: 'account-1', label: 'Test' },
scopes: [],
} as any);

const token = await getAuthorizationToken('test-tenant');
expect(token).toBe('Bearer test-token-123');
});

it('should return empty string when session returns no accessToken', async () => {
vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({
id: 'session-1',
account: { id: 'account-1', label: 'Test' },
scopes: [],
} as any);

const token = await getAuthorizationToken();
expect(token).toBe('');
});

it('should propagate errors when session acquisition fails', async () => {
vi.spyOn(azureAuth, 'getSessionFromVSCode').mockRejectedValue(new Error('Auth session expired'));

await expect(getAuthorizationToken()).rejects.toThrow('Auth session expired');
});

it('should pass tenantId to getSessionFromVSCode', async () => {
const spy = vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({
accessToken: 'tenant-token',
id: 'session-1',
account: { id: 'account-1', label: 'Test' },
scopes: [],
} as any);

await getAuthorizationToken('specific-tenant-id');
expect(spy).toHaveBeenCalledWith(undefined, 'specific-tenant-id', expect.any(Object));
});
});

describe('getAuthorizationTokenFromNode', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({
get: vi.fn(() => false),
} as any);
});

it('should throw when node is null/undefined', async () => {
await expect(getAuthorizationTokenFromNode(null as any)).rejects.toThrow();
});

it('should throw when node has no subscription', async () => {
const node = {} as any;
await expect(getAuthorizationTokenFromNode(node)).rejects.toThrow();
});

it('should return Bearer token from node subscription credentials', async () => {
const node = {
subscription: {
tenantId: 'tenant-1',
credentials: {
getToken: vi.fn().mockResolvedValue({ token: 'node-token-abc' }),
},
},
} as any;

const token = await getAuthorizationTokenFromNode(node);
expect(token).toBe('Bearer node-token-abc');
});

it('should fall back to getAuthorizationToken when credentials.getToken returns null', async () => {
vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({
accessToken: 'fallback-token',
id: 'session-1',
account: { id: 'account-1', label: 'Test' },
scopes: [],
} as any);

const node = {
subscription: {
tenantId: 'tenant-1',
credentials: {
getToken: vi.fn().mockResolvedValue(null),
},
},
} as any;

const token = await getAuthorizationTokenFromNode(node);
expect(token).toBe('Bearer fallback-token');
});

it('should fall back to getAuthorizationToken when no credentials exist', async () => {
vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({
accessToken: 'fallback-token-2',
id: 'session-1',
account: { id: 'account-1', label: 'Test' },
scopes: [],
} as any);

const node = {
subscription: {
tenantId: 'tenant-2',
credentials: undefined,
},
} as any;

const token = await getAuthorizationTokenFromNode(node);
expect(token).toBe('Bearer fallback-token-2');
});
});

describe('getCloudHost', () => {
it('should return the managementEndpointUrl from configured environment', async () => {
const host = await getCloudHost();
expect(host).toBe('https://management.azure.com');
});
});
Loading