diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 6f75a1e4e5..5463cef1b1 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -97,7 +97,7 @@ rules: - warn - "ignore": [-2, -1, 0, 1, 2, 4] no-multiple-empty-lines: warn - no-return-await: warn + no-return-await: off no-sequences: warn no-shadow: off no-sparse-arrays: warn diff --git a/.prettierignore b/.prettierignore index 931d5c614a..6f373139f5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,4 +9,5 @@ node_modules pnpm-lock.yaml **/bundle.l10n.json **/.wdio-vscode-service +packages/zowe-explorer/__tests__/**/zowe.config.json zedc/target \ No newline at end of file diff --git a/packages/zowe-explorer-api/CHANGELOG.md b/packages/zowe-explorer-api/CHANGELOG.md index 2b5ff142d3..c9beda2491 100644 --- a/packages/zowe-explorer-api/CHANGELOG.md +++ b/packages/zowe-explorer-api/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to the "zowe-explorer-api" extension will be documented in t - Added support for VS Code proxy settings with zosmf profile types. [#3010](https://github.com/zowe/zowe-explorer-vscode/issues/3010) - Added optional `getLocalStorage` function to the `IApiExplorerExtender` interface to expose local storage access to Zowe Explorer extenders. [#3180](https://github.com/zowe/zowe-explorer-vscode/issues/3180) - Added optional `setEncoding`, `getEncoding`, and `getEncodingInMap` functions to the `IZoweJobTreeNode` interface. [#3361](https://github.com/zowe/zowe-explorer-vscode/pull/3361) +- Added an `AuthHandler` class with functions for locking/unlocking profiles, prompting for credentials and SSO login support. Extenders can now lock profiles after an authentication error, ensuring that an invalid profile is not used asynchronously until the error is resolved. [#3329](https://github.com/zowe/zowe-explorer-vscode/issues/3329) ### Bug fixes diff --git a/packages/zowe-explorer-api/__mocks__/vscode.ts b/packages/zowe-explorer-api/__mocks__/vscode.ts index 532f340e5d..bc43c12017 100644 --- a/packages/zowe-explorer-api/__mocks__/vscode.ts +++ b/packages/zowe-explorer-api/__mocks__/vscode.ts @@ -456,6 +456,8 @@ export namespace window { close: jest.fn(), }; + export let activeTextEditor: TextDocument | undefined = { fileName: "placeholderFile.txt" } as any; + /** * Show an information message to users. Optionally provide an array of items which will be presented as * clickable buttons. @@ -1096,6 +1098,7 @@ export class FileSystemError extends Error { */ export namespace workspace { export const textDocuments: TextDocument[] = []; + export const workspaceFolders: readonly WorkspaceFolder[] | undefined = []; export function getConfiguration(_configuration: string) { return { update: () => { diff --git a/packages/zowe-explorer-api/__tests__/__unit__/profiles/AuthHandler.unit.test.ts b/packages/zowe-explorer-api/__tests__/__unit__/profiles/AuthHandler.unit.test.ts new file mode 100644 index 0000000000..1df9768d04 --- /dev/null +++ b/packages/zowe-explorer-api/__tests__/__unit__/profiles/AuthHandler.unit.test.ts @@ -0,0 +1,159 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { Mutex } from "async-mutex"; +import { AuthHandler, Gui } from "../../../src"; +import { FileManagement } from "../../../src/utils/FileManagement"; +import { ImperativeError } from "@zowe/imperative"; +import { AuthPromptParams } from "@zowe/zowe-explorer-api"; + +const TEST_PROFILE_NAME = "lpar.zosmf"; + +describe("AuthHandler.isProfileLocked", () => { + it("returns true if the profile is locked", async () => { + await AuthHandler.lockProfile(TEST_PROFILE_NAME); + expect(AuthHandler.isProfileLocked(TEST_PROFILE_NAME)).toBe(true); + AuthHandler.unlockProfile(TEST_PROFILE_NAME); + }); + + it("returns false if the profile is not locked", async () => { + expect(AuthHandler.isProfileLocked(TEST_PROFILE_NAME)).toBe(false); + }); + + it("returns false if no mutex is present for the given profile", async () => { + expect(AuthHandler.isProfileLocked("unused_lpar.zosmf")).toBe(false); + }); +}); + +describe("AuthHandler.lockProfile", () => { + it("assigns and acquires a Mutex to the profile in the profile map", async () => { + await AuthHandler.lockProfile(TEST_PROFILE_NAME); + expect((AuthHandler as any).profileLocks.has(TEST_PROFILE_NAME)).toBe(true); + expect((AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME)).toBeInstanceOf(Mutex); + AuthHandler.unlockProfile(TEST_PROFILE_NAME); + }); + + it("handle promptForAuthentication call if error and options are given", async () => { + const promptForAuthenticationMock = jest.spyOn(AuthHandler, "promptForAuthentication").mockResolvedValueOnce(true); + const imperativeError = new ImperativeError({ msg: "Example auth error" }); + const authOpts: AuthPromptParams = { + authMethods: { + promptCredentials: jest.fn(), + ssoLogin: jest.fn(), + }, + imperativeError, + }; + const releaseSpy = jest.spyOn(Mutex.prototype, "release"); + const result = await AuthHandler.lockProfile(TEST_PROFILE_NAME, authOpts); + expect(result).toBe(true); + expect(promptForAuthenticationMock).toHaveBeenCalledTimes(1); + expect(promptForAuthenticationMock).toHaveBeenCalledWith(TEST_PROFILE_NAME, authOpts); + expect(releaseSpy).toHaveBeenCalledTimes(1); + AuthHandler.unlockProfile(TEST_PROFILE_NAME); + }); + + it("reuses the same Mutex for the profile if it already exists", async () => { + await AuthHandler.lockProfile(TEST_PROFILE_NAME); + expect((AuthHandler as any).profileLocks.has(TEST_PROFILE_NAME)).toBe(true); + // cache initial mutex for comparison + const mutex = (AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME); + expect(mutex).toBeInstanceOf(Mutex); + AuthHandler.unlockProfile(TEST_PROFILE_NAME); + + // same mutex is still present in map since lock/unlock sequence was used + await AuthHandler.lockProfile(TEST_PROFILE_NAME); + expect(mutex).toBe((AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME)); + AuthHandler.unlockProfile(TEST_PROFILE_NAME); + }); +}); + +describe("AuthHandler.promptForAuthentication", () => { + it("handles a token-based authentication error - login successful, profile is string", async () => { + const tokenNotValidMsg = "Token is not valid or expired."; + const imperativeError = new ImperativeError({ additionalDetails: tokenNotValidMsg, msg: tokenNotValidMsg }); + const ssoLogin = jest.fn().mockResolvedValue(true); + const promptCredentials = jest.fn(); + const showMessageMock = jest.spyOn(Gui, "showMessage").mockResolvedValueOnce("Log in to Authentication Service"); + const unlockProfileSpy = jest.spyOn(AuthHandler, "unlockProfile"); + await expect( + AuthHandler.promptForAuthentication("lpar.zosmf", { authMethods: { promptCredentials, ssoLogin }, imperativeError }) + ).resolves.toBe(true); + expect(promptCredentials).not.toHaveBeenCalled(); + expect(ssoLogin).toHaveBeenCalledTimes(1); + expect(ssoLogin).toHaveBeenCalledWith(null, "lpar.zosmf"); + expect(unlockProfileSpy).toHaveBeenCalledTimes(1); + expect(unlockProfileSpy).toHaveBeenCalledWith("lpar.zosmf", true); + expect(showMessageMock).toHaveBeenCalledTimes(1); + }); + + it("handles a standard authentication error - credentials provided, profile is string", async () => { + const tokenNotValidMsg = "Invalid credentials"; + const imperativeError = new ImperativeError({ additionalDetails: tokenNotValidMsg, msg: tokenNotValidMsg }); + const ssoLogin = jest.fn().mockResolvedValue(true); + const promptCredentials = jest.fn().mockResolvedValue(["us3r", "p4ssw0rd"]); + const errorMessageMock = jest.spyOn(Gui, "errorMessage").mockResolvedValueOnce("Update Credentials"); + const unlockProfileSpy = jest.spyOn(AuthHandler, "unlockProfile").mockClear(); + await expect( + AuthHandler.promptForAuthentication("lpar.zosmf", { authMethods: { promptCredentials, ssoLogin }, imperativeError }) + ).resolves.toBe(true); + expect(unlockProfileSpy).toHaveBeenCalledTimes(1); + expect(unlockProfileSpy).toHaveBeenCalledWith("lpar.zosmf", true); + expect(ssoLogin).not.toHaveBeenCalled(); + expect(errorMessageMock).toHaveBeenCalledTimes(1); + expect(promptCredentials).toHaveBeenCalledTimes(1); + expect(promptCredentials).toHaveBeenCalledWith("lpar.zosmf", true); + }); +}); + +describe("AuthHandler.unlockProfile", () => { + it("releases the Mutex for the profile in the profile map", async () => { + await AuthHandler.lockProfile(TEST_PROFILE_NAME); + AuthHandler.unlockProfile(TEST_PROFILE_NAME); + expect((AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME)!.isLocked()).toBe(false); + }); + + it("does nothing if there is no mutex in the profile map", async () => { + const releaseSpy = jest.spyOn(Mutex.prototype, "release").mockClear(); + AuthHandler.unlockProfile("unused_lpar.zosmf"); + expect(releaseSpy).not.toHaveBeenCalled(); + }); + + it("does nothing if the mutex in the map is not locked", async () => { + await AuthHandler.lockProfile(TEST_PROFILE_NAME); + AuthHandler.unlockProfile(TEST_PROFILE_NAME); + + const releaseSpy = jest.spyOn(Mutex.prototype, "release").mockClear(); + AuthHandler.unlockProfile(TEST_PROFILE_NAME); + expect(releaseSpy).not.toHaveBeenCalled(); + }); + + it("reuses the same Mutex for the profile if it already exists", async () => { + await AuthHandler.lockProfile(TEST_PROFILE_NAME); + AuthHandler.unlockProfile(TEST_PROFILE_NAME); + expect((AuthHandler as any).profileLocks.has(TEST_PROFILE_NAME)).toBe(true); + // cache initial mutex for comparison + const mutex = (AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME); + + // same mutex is still present in map since lock/unlock sequence was used + await AuthHandler.lockProfile(TEST_PROFILE_NAME); + AuthHandler.unlockProfile(TEST_PROFILE_NAME); + expect(mutex).toBe((AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME)); + }); + + it("refreshes resources if refreshResources parameter is true", async () => { + const reloadActiveEditorMock = jest.spyOn(FileManagement, "reloadActiveEditorForProfile").mockResolvedValueOnce(undefined); + const reloadWorkspaceMock = jest.spyOn(FileManagement, "reloadWorkspacesForProfile").mockResolvedValueOnce(undefined); + await AuthHandler.lockProfile(TEST_PROFILE_NAME); + AuthHandler.unlockProfile(TEST_PROFILE_NAME, true); + expect(reloadActiveEditorMock).toHaveBeenCalledWith(TEST_PROFILE_NAME); + expect(reloadWorkspaceMock).toHaveBeenCalledWith(TEST_PROFILE_NAME); + }); +}); diff --git a/packages/zowe-explorer-api/__tests__/__unit__/utils/DeferredPromise.unit.test.ts b/packages/zowe-explorer-api/__tests__/__unit__/utils/DeferredPromise.unit.test.ts new file mode 100644 index 0000000000..508abf4db9 --- /dev/null +++ b/packages/zowe-explorer-api/__tests__/__unit__/utils/DeferredPromise.unit.test.ts @@ -0,0 +1,47 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { DeferredPromise, DeferredPromiseStatus } from "../../../src"; + +describe("DeferredPromise constructor", () => { + it("sets resolve and reject functions", () => { + const deferred = new DeferredPromise(); + expect(deferred.promise).toBeInstanceOf(Promise); + expect(deferred.reject).toBeInstanceOf(Function); + expect(deferred.resolve).toBeInstanceOf(Function); + }); +}); + +describe("DeferredPromise.status", () => { + it("returns pending when not yet resolved", () => { + const deferred = new DeferredPromise(); + expect(deferred.status).toBe(DeferredPromiseStatus.Pending); + }); + + it("returns resolved when resolved", () => { + const deferred = new DeferredPromise(); + deferred.resolve(null); + expect(deferred.status).toBe(DeferredPromiseStatus.Resolved); + }); + + it("returns rejected when rejected", async () => { + const deferred = new DeferredPromise(); + let errorCaught = false; + setImmediate(() => deferred.reject()); + try { + await deferred.promise; + } catch (err) { + errorCaught = true; + } + expect(deferred.status).toBe(DeferredPromiseStatus.Rejected); + expect(errorCaught).toBe(true); + }); +}); diff --git a/packages/zowe-explorer-api/__tests__/__unit__/utils/FileManagement.unit.test.ts b/packages/zowe-explorer-api/__tests__/__unit__/utils/FileManagement.unit.test.ts new file mode 100644 index 0000000000..59d2af04cc --- /dev/null +++ b/packages/zowe-explorer-api/__tests__/__unit__/utils/FileManagement.unit.test.ts @@ -0,0 +1,111 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { FileSystemError, FileType, Uri, window, workspace } from "vscode"; +import { FileManagement } from "../../../src/utils/FileManagement"; +import { IFileSystemEntry, ZoweScheme } from "../../../src"; + +describe("permStringToOctal", () => { + it("converts drwxrwxrwx to 777", () => { + expect(FileManagement.permStringToOctal("drwxrwxrwx")).toBe(777); + }); + + it("converts d--------- to 0", () => { + expect(FileManagement.permStringToOctal("d---------")).toBe(0); + }); + + it("converts drwxr-xr-x to 755", () => { + expect(FileManagement.permStringToOctal("drwxr-xr-x")).toBe(755); + }); + + it("converts -rwxrwxrwx to 777", () => { + expect(FileManagement.permStringToOctal("-rwxrwxrwx")).toBe(777); + }); +}); + +describe("reloadActiveEditorForProfile", () => { + it("calls workspace.fs.{readFile,stat} to reload contents of editor", async () => { + const fakeFsEntry: IFileSystemEntry = { + name: "exampleFile.txt", + wasAccessed: true, + type: FileType.Directory, + metadata: { + path: "/sestest/exampleFolder/exampleFile.txt", + profile: { + name: "sestest", + message: "", + type: "zosmf", + failNotFound: true, + }, + }, + ctime: Date.now() - 10, + mtime: Date.now(), + size: 123, + }; + const fileUri = Uri.from({ scheme: ZoweScheme.USS, path: "/sestest/exampleFolder/exampleFile.txt" }); + const activeTextEditorMock = jest.replaceProperty(window, "activeTextEditor", { + document: { + fileName: "exampleFile.txt", + uri: fileUri, + } as any, + } as any); + const statMock = jest.spyOn(workspace.fs, "stat").mockResolvedValueOnce(fakeFsEntry); + const readFileMock = jest.spyOn(workspace.fs, "readFile").mockImplementationOnce(async (uri): Promise => { + // wasAccessed flag should be false after reassigning in reloadActiveEditorForProfile + expect(fakeFsEntry.wasAccessed).toBe(false); + return new Uint8Array([1, 2, 3]); + }); + await FileManagement.reloadActiveEditorForProfile("sestest"); + expect(statMock).toHaveBeenCalledTimes(1); + expect(statMock).toHaveBeenCalledWith(fileUri); + expect(readFileMock).toHaveBeenCalledTimes(1); + expect(readFileMock).toHaveBeenCalledWith(fileUri); + activeTextEditorMock.restore(); + }); +}); + +describe("reloadWorkspacesForProfile", () => { + it("calls workspace.fs.stat with fetch=true for each workspace folder", async () => { + const folderUri = Uri.from({ scheme: ZoweScheme.USS, path: "/sestest/exampleFolder" }); + const workspaceFoldersMock = jest.replaceProperty(workspace, "workspaceFolders", [ + { + uri: folderUri, + name: "exampleFolder", + index: 0, + }, + ]); + const statMock = jest + .spyOn(workspace.fs, "stat") + .mockClear() + .mockResolvedValueOnce(undefined as any); + await FileManagement.reloadWorkspacesForProfile("sestest"); + expect(statMock).toHaveBeenCalledTimes(1); + expect(statMock).toHaveBeenCalledWith(folderUri.with({ query: "fetch=true" })); + workspaceFoldersMock.restore(); + }); + it("calls console.error in event of an error", async () => { + const folderUri = Uri.from({ scheme: ZoweScheme.USS, path: "/sestest/exampleFolder" }); + const workspaceFoldersMock = jest.replaceProperty(workspace, "workspaceFolders", [ + { + uri: folderUri, + name: "exampleFolder", + index: 0, + }, + ]); + const statMock = jest.spyOn(workspace.fs, "stat").mockClear().mockRejectedValueOnce(FileSystemError.FileNotFound(folderUri)); + const consoleErrorMock = jest.spyOn(console, "error").mockImplementationOnce(() => {}); + await FileManagement.reloadWorkspacesForProfile("sestest"); + expect(statMock).toHaveBeenCalledTimes(1); + expect(statMock).toHaveBeenCalledWith(folderUri.with({ query: "fetch=true" })); + expect(consoleErrorMock).toHaveBeenCalledWith("reloadWorkspacesForProfile:", "file not found"); + workspaceFoldersMock.restore(); + }); +}); diff --git a/packages/zowe-explorer-api/__tests__/__unit__/utils/files.unit.test.ts b/packages/zowe-explorer-api/__tests__/__unit__/utils/files.unit.test.ts deleted file mode 100644 index 47d8aed881..0000000000 --- a/packages/zowe-explorer-api/__tests__/__unit__/utils/files.unit.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * This program and the accompanying materials are made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, and is available at - * https://www.eclipse.org/legal/epl-v20.html - * - * SPDX-License-Identifier: EPL-2.0 - * - * Copyright Contributors to the Zowe Project. - * - */ - -import { FileManagement } from "../../../src/utils/FileManagement"; - -describe("utils/file.ts", () => { - describe("permStringToOctal", () => { - it("converts drwxrwxrwx to 777", () => { - expect(FileManagement.permStringToOctal("drwxrwxrwx")).toBe(777); - }); - - it("converts d--------- to 0", () => { - expect(FileManagement.permStringToOctal("d---------")).toBe(0); - }); - - it("converts drwxr-xr-x to 755", () => { - expect(FileManagement.permStringToOctal("drwxr-xr-x")).toBe(755); - }); - - it("converts -rwxrwxrwx to 777", () => { - expect(FileManagement.permStringToOctal("-rwxrwxrwx")).toBe(777); - }); - }); -}); diff --git a/packages/zowe-explorer-api/package.json b/packages/zowe-explorer-api/package.json index 1b97472dfe..fc796afbbc 100644 --- a/packages/zowe-explorer-api/package.json +++ b/packages/zowe-explorer-api/package.json @@ -37,6 +37,7 @@ "@zowe/zos-tso-for-zowe-sdk": "^8.8.3", "@zowe/zos-uss-for-zowe-sdk": "^8.8.3", "@zowe/zosmf-for-zowe-sdk": "^8.8.3", + "async-mutex": "^0.5.0", "deep-object-diff": "^1.1.9", "mustache": "^4.2.0", "semver": "^7.6.0" diff --git a/packages/zowe-explorer-api/src/profiles/AuthHandler.ts b/packages/zowe-explorer-api/src/profiles/AuthHandler.ts new file mode 100644 index 0000000000..4a9ec49841 --- /dev/null +++ b/packages/zowe-explorer-api/src/profiles/AuthHandler.ts @@ -0,0 +1,181 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { Gui } from "../globals"; +import { CorrelatedError, FileManagement } from "../utils"; +import * as imperative from "@zowe/imperative"; +import { IZoweTreeNode } from "../tree"; +import { Mutex } from "async-mutex"; +import { ZoweVsCodeExtension } from "../vscode/ZoweVsCodeExtension"; + +/** + * @brief individual authentication methods (also supports a `ProfilesCache` class) + */ +export interface IAuthMethods { + // Method for establishing SSO login with a given profile name + ssoLogin: (node?: IZoweTreeNode, profileName?: string) => PromiseLike; + // Method that prompts the user for credentials, sets them on the profile and returns them to the caller if set + promptCredentials: (profile: string | imperative.IProfileLoaded, rePrompt?: boolean) => PromiseLike; +} + +export type AuthPromptParams = { + // Whether the profile is using token-based authentication + isUsingTokenAuth?: boolean; + // API-friendly error correlation for the "Invalid Credentials" scenario + errorCorrelation?: CorrelatedError; + // Authentication methods to call after user responds to prompts + authMethods: IAuthMethods; + // Error encountered from API call + imperativeError: imperative.ImperativeError; +}; + +export type ProfileLike = string | imperative.IProfileLoaded; +export class AuthHandler { + private static profileLocks: Map = new Map(); + + /** + * Function that checks whether a profile is using token based authentication + * @param {string[]} secureProfileProps Secure properties for the service profile + * @param {string[]} baseSecureProfileProps Base profile's secure properties (optional) + * @returns {Promise} a boolean representing whether token based auth is being used or not + */ + public static isUsingTokenAuth(secureProfileProps: string[], baseSecureProfileProps?: string[]): boolean { + const profileUsesBasicAuth = secureProfileProps.includes("user") && secureProfileProps.includes("password"); + if (secureProfileProps.includes("tokenValue")) { + return !profileUsesBasicAuth; + } + return baseSecureProfileProps?.includes("tokenValue") && !profileUsesBasicAuth; + } + + /** + * Unlocks the given profile so it can be used again. + * @param profile {ProfileLike} The profile (name or {@link imperative.IProfileLoaded} object) to unlock + * @param refreshResources {boolean} Whether to refresh high-priority resources (active editor & virtual workspace) after unlocking + */ + public static unlockProfile(profile: ProfileLike, refreshResources?: boolean): void { + const profileName = typeof profile === "string" ? profile : profile.name; + const mutex = this.profileLocks.get(profileName); + // If a mutex doesn't exist for this profile or the mutex is no longer locked, return + if (mutex == null || !mutex.isLocked()) { + return; + } + + mutex.release(); + if (refreshResources) { + // TODO: Log errors using ZoweLogger once available in ZE API + // refresh an active, unsaved editor if it uses the profile + FileManagement.reloadActiveEditorForProfile(profileName) + // eslint-disable-next-line no-console + .catch((err) => err instanceof Error && console.error(err.message)); + + // refresh virtual workspaces for the profile + FileManagement.reloadWorkspacesForProfile(profileName) + // eslint-disable-next-line no-console + .catch((err) => err instanceof Error && console.error(err.message)); + } + } + + /** + * Prompts the user to authenticate over SSO or a credential prompt in the event of an error. + * @param profile The profile to authenticate + * @param params {AuthPromptParams} Prompt parameters (login methods, using token auth, error correlation) + * @returns {boolean} Whether authentication was successful + */ + public static async promptForAuthentication(profile: ProfileLike, params: AuthPromptParams): Promise { + const profileName = typeof profile === "string" ? profile : profile.name; + if (params.imperativeError.mDetails.additionalDetails) { + const tokenError: string = params.imperativeError.mDetails.additionalDetails; + if (tokenError.includes("Token is not valid or expired.") || params.isUsingTokenAuth) { + // Handle token-based authentication error through the given `ssoLogin` method. + const message = "Log in to Authentication Service"; + const userResp = await Gui.showMessage(params.errorCorrelation?.message ?? params.imperativeError.message, { + items: [message], + vsCodeOpts: { modal: true }, + }); + if (userResp === message && (await params.authMethods.ssoLogin(null, profileName))) { + // SSO login was successful, propagate new profile properties to other tree providers + if (typeof profile !== "string") { + ZoweVsCodeExtension.onProfileUpdatedEmitter.fire(profile); + } + // Unlock profile so it can be used again + AuthHandler.unlockProfile(profileName, true); + return true; + } + return false; + } + } + + // Prompt the user to update their credentials using the given `promptCredentials` method. + const checkCredsButton = "Update Credentials"; + const creds = await Gui.errorMessage(params.errorCorrelation?.message ?? params.imperativeError.message, { + items: [checkCredsButton], + vsCodeOpts: { modal: true }, + }).then(async (selection) => { + if (selection !== checkCredsButton) { + return; + } + return params.authMethods.promptCredentials(profile, true); + }); + + if (creds != null) { + // New creds were set, propagate new profile properties to other tree providers. + if (typeof profile !== "string") { + ZoweVsCodeExtension.onProfileUpdatedEmitter.fire(profile); + } + // Unlock profile so it can be used again + AuthHandler.unlockProfile(profileName, true); + return true; + } + return false; + } + + /** + * Locks the given profile to prevent further use in asynchronous operations (at least where the lock is respected). + * Supports prompting for authentication if an Imperative error and prompt options are given. + * @param profile The profile to lock + * @param authOpts Authentication methods and related options. If provided, {@link promptForAuthentication} will be called with the given options. + * @returns Whether the profile was successfully locked + */ + public static async lockProfile(profile: ProfileLike, authOpts?: AuthPromptParams): Promise { + const profileName = typeof profile === "string" ? profile : profile.name; + + // If the mutex does not exist, make one for the profile and acquire the lock + if (!this.profileLocks.has(profileName)) { + this.profileLocks.set(profileName, new Mutex()); + } + + // Attempt to acquire the lock + const mutex = this.profileLocks.get(profileName); + await mutex.acquire(); + + // Prompt the user to re-authenticate if an error and options were provided + if (authOpts) { + await AuthHandler.promptForAuthentication(profile, authOpts); + mutex.release(); + } + + return true; + } + + /** + * Checks whether the given profile has its lock acquired. + * @param profile The profile to check + * @returns {boolean} `true` if the given profile is locked, `false` otherwise + */ + public static isProfileLocked(profile: ProfileLike): boolean { + const mutex = this.profileLocks.get(typeof profile === "string" ? profile : profile.name); + if (mutex == null) { + return false; + } + + return mutex.isLocked(); + } +} diff --git a/packages/zowe-explorer-api/src/profiles/ProfilesCache.ts b/packages/zowe-explorer-api/src/profiles/ProfilesCache.ts index e57067336a..275e434942 100644 --- a/packages/zowe-explorer-api/src/profiles/ProfilesCache.ts +++ b/packages/zowe-explorer-api/src/profiles/ProfilesCache.ts @@ -11,7 +11,7 @@ import * as imperative from "@zowe/imperative"; import type { IRegisterClient } from "../extend/IRegisterClient"; -import { FileManagement } from "../utils"; +import { FileManagement } from "../utils/FileManagement"; import { Validation } from "./Validation"; import { ZosmfProfile } from "@zowe/zosmf-for-zowe-sdk"; import { ZosTsoProfile } from "@zowe/zos-tso-for-zowe-sdk"; diff --git a/packages/zowe-explorer-api/src/profiles/index.ts b/packages/zowe-explorer-api/src/profiles/index.ts index bda888f97d..182cf564f5 100644 --- a/packages/zowe-explorer-api/src/profiles/index.ts +++ b/packages/zowe-explorer-api/src/profiles/index.ts @@ -9,6 +9,7 @@ * */ +export * from "./AuthHandler"; export * from "./Validation"; export * from "./UserSettings"; export * from "./ProfilesCache"; diff --git a/packages/zowe-explorer-api/src/utils/DeferredPromise.ts b/packages/zowe-explorer-api/src/utils/DeferredPromise.ts new file mode 100644 index 0000000000..20b8fcfd22 --- /dev/null +++ b/packages/zowe-explorer-api/src/utils/DeferredPromise.ts @@ -0,0 +1,53 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +/* Status of the deferred promise */ +export enum DeferredPromiseStatus { + Pending = "pending", + Resolved = "resolved", + Rejected = "rejected", +} + +/** + * @brief Externally control the resolution and rejection of a promise. + * + * @details + * Creates a promise with accessible `resolve` and `reject` methods, enabling external entities to + * settle the promise based on custom logic or asynchronous events. This is particularly useful when + * the promise's outcome depends on factors outside the immediate context. + */ +export class DeferredPromise { + private mStatus: DeferredPromiseStatus = DeferredPromiseStatus.Pending; + + public promise: Promise; + public resolve: (value: T | PromiseLike) => void; + public reject: (reason?: any) => void; + + public constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = (value): void => { + this.mStatus = DeferredPromiseStatus.Resolved; + resolve(value); + }; + this.reject = (err): void => { + this.mStatus = DeferredPromiseStatus.Rejected; + reject(err); + }; + }); + } + + /** + * @returns {PromiseStatus} The status of the deferred promise + */ + public get status(): DeferredPromiseStatus { + return this.mStatus; + } +} diff --git a/packages/zowe-explorer-api/src/utils/FileManagement.ts b/packages/zowe-explorer-api/src/utils/FileManagement.ts index 1fa8ff4637..7e2c983cb7 100644 --- a/packages/zowe-explorer-api/src/utils/FileManagement.ts +++ b/packages/zowe-explorer-api/src/utils/FileManagement.ts @@ -13,6 +13,8 @@ import { realpathSync } from "fs"; import { platform } from "os"; import { Constants } from "../globals"; import { ImperativeConfig, ConfigUtils } from "@zowe/imperative"; +import { IFileSystemEntry, ZoweScheme } from "../fs/types"; +import { window, workspace } from "vscode"; export class FileManagement { public static permStringToOctal(perms: string): number { @@ -50,4 +52,36 @@ export class FileManagement { } return realpathSync(anyPath); } + + public static async reloadActiveEditorForProfile(profileName: string): Promise { + const document = window.activeTextEditor?.document; + if ( + document != null && + (Object.values(ZoweScheme) as string[]).includes(document.uri.scheme) && + document.uri.path.startsWith(`/${profileName}/`) && + !document.isDirty + ) { + const fsEntry = (await workspace.fs.stat(document.uri)) as IFileSystemEntry; + fsEntry.wasAccessed = false; + await workspace.fs.readFile(document.uri); + } + } + + public static async reloadWorkspacesForProfile(profileName: string): Promise { + const foldersWithProfile = (workspace.workspaceFolders ?? []).filter( + (f) => (f.uri.scheme === ZoweScheme.DS || f.uri.scheme === ZoweScheme.USS) && f.uri.path.startsWith(`/${profileName}/`) + ); + for (const folder of foldersWithProfile) { + try { + await workspace.fs.stat(folder.uri.with({ query: "fetch=true" })); + } catch (err) { + if (err instanceof Error) { + // TODO: Remove console.error in favor of logger + // (need to move logger to ZE API) + // eslint-disable-next-line no-console + console.error("reloadWorkspacesForProfile:", err.message); + } + } + } + } } diff --git a/packages/zowe-explorer-api/src/utils/index.ts b/packages/zowe-explorer-api/src/utils/index.ts index bb4931a173..af7887d5ad 100644 --- a/packages/zowe-explorer-api/src/utils/index.ts +++ b/packages/zowe-explorer-api/src/utils/index.ts @@ -12,6 +12,7 @@ import { IZoweTreeNode } from "../tree"; import { workspace } from "vscode"; +export * from "./DeferredPromise"; export * from "./ErrorCorrelator"; export * from "./Poller"; export * from "./FileManagement"; diff --git a/packages/zowe-explorer-api/src/vscode/ZoweVsCodeExtension.ts b/packages/zowe-explorer-api/src/vscode/ZoweVsCodeExtension.ts index 6d39ddd41b..bac56b7867 100644 --- a/packages/zowe-explorer-api/src/vscode/ZoweVsCodeExtension.ts +++ b/packages/zowe-explorer-api/src/vscode/ZoweVsCodeExtension.ts @@ -15,9 +15,9 @@ import { ProfilesCache } from "../profiles/ProfilesCache"; import { Login, Logout } from "@zowe/core-for-zowe-sdk"; import * as imperative from "@zowe/imperative"; import { Gui } from "../globals/Gui"; -import { PromptCredentialsOptions } from "./doc/PromptCredentials"; +import type { PromptCredentialsOptions } from "./doc/PromptCredentials"; import { Types } from "../Types"; -import { BaseProfileAuthOptions } from "./doc/BaseProfileAuth"; +import type { BaseProfileAuthOptions } from "./doc/BaseProfileAuth"; /** * Collection of utility functions for writing Zowe Explorer VS Code extensions. @@ -27,6 +27,9 @@ export class ZoweVsCodeExtension { return vscode.workspace.workspaceFolders?.find((f) => f.uri.scheme === "file"); } + public static onProfileUpdatedEmitter: vscode.EventEmitter = new vscode.EventEmitter(); + public static readonly onProfileUpdated = ZoweVsCodeExtension.onProfileUpdatedEmitter.event; + /** * @internal */ diff --git a/packages/zowe-explorer/CHANGELOG.md b/packages/zowe-explorer/CHANGELOG.md index d4f3c8ae43..b5ffb53938 100644 --- a/packages/zowe-explorer/CHANGELOG.md +++ b/packages/zowe-explorer/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to the "vscode-extension-for-zowe" extension will be documen - Fixed an issue where binary USS files were not fetched using the "Pull from Mainframe" context menu option. [#3355](https://github.com/zowe/zowe-explorer-vscode/issues/3355) - Fixed an issue where cached encoding was applied for all profiles with the same data set or USS path in the "Open with Encoding" menu. [#3363](https://github.com/zowe/zowe-explorer-vscode/pull/3363) - Removed "Delete Profile" action from the "Manage Profile" menu since this action is currently not supported in Zowe Explorer. [#3037](https://github.com/zowe/zowe-explorer-vscode/issues/3037) +- Fixed an issue where the filesystem continued to use a profile with invalid credentials to fetch resources. Now, after an authentication error occurs for a profile, it cannot be used again in the filesystem until the authentication error is resolved. [#3329](https://github.com/zowe/zowe-explorer-vscode/issues/3329) ## `3.0.3` diff --git a/packages/zowe-explorer/__tests__/__common__/testUtils.ts b/packages/zowe-explorer/__tests__/__common__/testUtils.ts index 861ff1cbcc..61352adea7 100644 --- a/packages/zowe-explorer/__tests__/__common__/testUtils.ts +++ b/packages/zowe-explorer/__tests__/__common__/testUtils.ts @@ -46,7 +46,10 @@ export function processSubscriptions(subscriptions: IJestIt[], test: ITestContex spyOnSubscription(sub); it(sub.title ?? `Test: ${sub.name}`, async () => { const parms = sub.parm ?? [test.value]; - await test.context.subscriptions.find((s) => Object.keys(s)[0] === getName(sub.name))?.[getName(sub.name)](...parms); + await test.context.subscriptions + .filter(Boolean) + .find((s) => Object.keys(s)[0] === getName(sub.name)) + ?.[getName(sub.name)](...parms); sub.mock.forEach((mock) => { expect(mock.spy).toHaveBeenCalledWith(...mock.arg); }); diff --git a/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts index 35a2658243..9220e057a1 100644 --- a/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/extension.unit.test.ts @@ -17,7 +17,7 @@ import * as fsextra from "fs-extra"; import * as extension from "../../src/extension"; import * as zosfiles from "@zowe/zos-files-for-zowe-sdk"; import * as zosmf from "@zowe/zosmf-for-zowe-sdk"; -import { imperative, Gui, Validation, ProfilesCache, FileManagement } from "@zowe/zowe-explorer-api"; +import { imperative, Gui, Validation, ProfilesCache, FileManagement, ZoweVsCodeExtension } from "@zowe/zowe-explorer-api"; import { createGetConfigMock, createInstanceOfProfileInfo, createIProfile, createTreeView } from "../__mocks__/mockCreators/shared"; import { Constants } from "../../src/configuration/Constants"; import { Profiles } from "../../src/configuration/Profiles"; @@ -249,6 +249,7 @@ async function createGlobalMocks() { ], }; + jest.replaceProperty(ZoweVsCodeExtension, "onProfileUpdated", jest.fn()); Object.defineProperty(fs, "mkdirSync", { value: globalMocks.mockMkdirSync, configurable: true }); Object.defineProperty(vscode.window, "createTreeView", { value: globalMocks.mockCreateTreeView, diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetFSProvider.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetFSProvider.unit.test.ts index e8c10345be..634e450174 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetFSProvider.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/dataset/DatasetFSProvider.unit.test.ts @@ -27,6 +27,7 @@ import { import { MockedProperty } from "../../../__mocks__/mockUtils"; import { DatasetFSProvider } from "../../../../src/trees/dataset/DatasetFSProvider"; import { ZoweExplorerApiRegister } from "../../../../src/extending/ZoweExplorerApiRegister"; +import { AuthUtils } from "../../../../src/utils/AuthUtils"; const dayjs = require("dayjs"); const testProfile = createIProfile(); @@ -293,7 +294,7 @@ describe("readFile", () => { it("throws an error if the entry does not have a profile", async () => { const _lookupAsFileMock = jest .spyOn(DatasetFSProvider.instance as any, "_lookupAsFile") - .mockReturnValueOnce({ ...testEntries.ps, metadata: { profile: null } }); + .mockReturnValueOnce({ ...testEntries.ps, metadata: { profile: undefined } }); let err; try { @@ -754,12 +755,12 @@ describe("fetchEntriesForDataset", () => { expect(allMembersMock).toHaveBeenCalled(); mvsApiMock.mockRestore(); }); - it("calls _handleError in the case of an API error", async () => { + it("calls handleProfileAuthOnError in the case of an API error", async () => { const allMembersMock = jest.fn().mockRejectedValue(new Error("API error")); const mvsApiMock = jest.spyOn(ZoweExplorerApiRegister, "getMvsApi").mockReturnValue({ allMembers: allMembersMock, } as any); - const _handleErrorMock = jest.spyOn(DatasetFSProvider.instance as any, "_handleError").mockImplementation(); + const handleProfileAuthOnErrorMock = jest.spyOn(AuthUtils, "handleProfileAuthOnError").mockImplementation(); const fakePds = Object.assign(Object.create(Object.getPrototypeOf(testEntries.pds)), testEntries.pds); await expect( (DatasetFSProvider.instance as any).fetchEntriesForDataset(fakePds, testUris.pds, { @@ -770,8 +771,8 @@ describe("fetchEntriesForDataset", () => { }) ).rejects.toThrow(); expect(allMembersMock).toHaveBeenCalled(); - expect(_handleErrorMock).toHaveBeenCalled(); - _handleErrorMock.mockRestore(); + expect(handleProfileAuthOnErrorMock).toHaveBeenCalled(); + handleProfileAuthOnErrorMock.mockRestore(); mvsApiMock.mockRestore(); }); }); diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/job/JobFSProvider.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/job/JobFSProvider.unit.test.ts index f32fa0662a..c3040a788a 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/job/JobFSProvider.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/job/JobFSProvider.unit.test.ts @@ -291,7 +291,7 @@ describe("fetchSpoolAtUri", () => { throw new Error("Failed to download spool"); }), }; - const promptForAuthErrorMock = jest.spyOn(AuthUtils, "promptForAuthError").mockImplementation(); + const promptForAuthErrorMock = jest.spyOn(AuthUtils, "handleProfileAuthOnError").mockImplementation(); const jesApiMock = jest.spyOn(ZoweExplorerApiRegister, "getJesApi").mockReturnValueOnce(mockJesApi as any); await expect(JobFSProvider.instance.fetchSpoolAtUri(testUris.spool)).rejects.toThrow(); expect(promptForAuthErrorMock).toHaveBeenCalled(); diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedInit.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedInit.unit.test.ts index d7794961a5..03eae51c60 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedInit.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/shared/SharedInit.unit.test.ts @@ -26,7 +26,7 @@ import { UnixCommandHandler } from "../../../../src/commands/UnixCommandHandler" import { SharedTreeProviders } from "../../../../src/trees/shared/SharedTreeProviders"; import { SharedContext } from "../../../../src/trees/shared/SharedContext"; import * as certWizard from "../../../../src/utils/CertificateWizard"; -import { Gui, imperative, ZoweScheme } from "@zowe/zowe-explorer-api"; +import { Gui, imperative, ZoweScheme, ZoweVsCodeExtension } from "@zowe/zowe-explorer-api"; import { MockedProperty } from "../../../__mocks__/mockUtils"; import { DatasetFSProvider } from "../../../../src/trees/dataset/DatasetFSProvider"; import { UssFSProvider } from "../../../../src/trees/uss/UssFSProvider"; @@ -64,6 +64,8 @@ describe("Test src/shared/extension", () => { ssoLogin: jest.fn(), ssoLogout: jest.fn(), }; + jest.replaceProperty(ZoweVsCodeExtension, "onProfileUpdated", jest.fn()); + const commands: IJestIt[] = [ { name: "zowe.updateSecureCredentials", diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/uss/UssFSProvider.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/uss/UssFSProvider.unit.test.ts index 741c053a92..9fde97d3ef 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/uss/UssFSProvider.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/UssFSProvider.unit.test.ts @@ -10,7 +10,7 @@ */ import { Disposable, FilePermission, FileSystemError, FileType, TextEditor, Uri, workspace } from "vscode"; -import { BaseProvider, DirEntry, FileEntry, Gui, UssDirectory, UssFile, ZoweExplorerApiType, ZoweScheme } from "@zowe/zowe-explorer-api"; +import { AuthHandler, BaseProvider, DirEntry, FileEntry, Gui, UssDirectory, UssFile, ZoweExplorerApiType, ZoweScheme } from "@zowe/zowe-explorer-api"; import { Profiles } from "../../../../src/configuration/Profiles"; import { createIProfile } from "../../../__mocks__/mockCreators/shared"; import { ZoweExplorerApiRegister } from "../../../../src/extending/ZoweExplorerApiRegister"; @@ -466,6 +466,8 @@ describe("autoDetectEncoding", () => { let mockUssApi; beforeEach(() => { + jest.spyOn(AuthHandler, "lockProfile").mockImplementation(); + jest.spyOn(AuthHandler, "unlockProfile").mockImplementation(); mockUssApi = jest.spyOn(ZoweExplorerApiRegister, "getUssApi").mockReturnValue({ getTag: getTagMock.mockClear(), } as any); diff --git a/packages/zowe-explorer/__tests__/__unit__/utils/AuthUtils.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/utils/AuthUtils.unit.test.ts index ecbdb4bb45..c5ed7c0366 100644 --- a/packages/zowe-explorer/__tests__/__unit__/utils/AuthUtils.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/utils/AuthUtils.unit.test.ts @@ -9,35 +9,50 @@ * */ -import { ErrorCorrelator, Gui, imperative, ZoweExplorerApiType } from "@zowe/zowe-explorer-api"; +import { AuthHandler, ErrorCorrelator, Gui, imperative, ZoweExplorerApiType } from "@zowe/zowe-explorer-api"; import { AuthUtils } from "../../../src/utils/AuthUtils"; import { Constants } from "../../../src/configuration/Constants"; import { MockedProperty } from "../../__mocks__/mockUtils"; describe("AuthUtils", () => { - describe("promptForAuthError", () => { + describe("handleProfileAuthOnError", () => { it("should prompt for authentication", async () => { - const errorDetails = new imperative.ImperativeError({ + const imperativeError = new imperative.ImperativeError({ errorCode: 401 as unknown as string, msg: "All configured authentication methods failed", }); const profile = { name: "aProfile", type: "zosmf" } as any; + const profilesCacheMock = new MockedProperty(Constants, "PROFILES_CACHE", { + value: { + ssoLogin: jest.fn().mockImplementation(), + promptCredentials: jest.fn().mockImplementation(), + } as any, + configurable: true, + }); const correlateErrorMock = jest.spyOn(ErrorCorrelator.getInstance(), "correlateError"); - const correlatedError = ErrorCorrelator.getInstance().correlateError(ZoweExplorerApiType.All, errorDetails, { + const errorCorrelation = ErrorCorrelator.getInstance().correlateError(ZoweExplorerApiType.All, imperativeError, { templateArgs: { profileName: profile.name, }, }); - const promptForAuthenticationMock = jest - .spyOn(AuthUtils, "promptForAuthentication") - .mockImplementation(async () => Promise.resolve(true)); - AuthUtils.promptForAuthError(errorDetails, profile); - expect(correlateErrorMock).toHaveBeenCalledWith(ZoweExplorerApiType.All, errorDetails, { + const isUsingTokenAuthMock = jest.spyOn(AuthUtils, "isUsingTokenAuth").mockResolvedValueOnce(false); + const promptForAuthenticationMock = jest.spyOn(AuthHandler, "promptForAuthentication").mockResolvedValueOnce(true); + await AuthUtils.handleProfileAuthOnError(imperativeError, profile); + expect(correlateErrorMock).toHaveBeenCalledWith(ZoweExplorerApiType.All, imperativeError, { templateArgs: { profileName: profile.name, }, }); - expect(promptForAuthenticationMock).toHaveBeenCalledWith(errorDetails, profile, correlatedError); + expect(promptForAuthenticationMock).toHaveBeenCalledWith( + profile, + expect.objectContaining({ + imperativeError, + errorCorrelation, + isUsingTokenAuth: false, + }) + ); + profilesCacheMock[Symbol.dispose](); + isUsingTokenAuthMock.mockRestore(); }); }); describe("promptForSsoLogin", () => { diff --git a/packages/zowe-explorer/__tests__/__unit__/utils/ProfilesUtils.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/utils/ProfilesUtils.unit.test.ts index bc6da9145d..a6e290349c 100644 --- a/packages/zowe-explorer/__tests__/__unit__/utils/ProfilesUtils.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/utils/ProfilesUtils.unit.test.ts @@ -13,8 +13,14 @@ import * as fs from "fs"; import * as path from "path"; import * as util from "util"; import * as vscode from "vscode"; -import { Gui, imperative, ProfilesCache, ZoweVsCodeExtension } from "@zowe/zowe-explorer-api"; -import { createAltTypeIProfile, createInstanceOfProfile, createValidIProfile } from "../../__mocks__/mockCreators/shared"; +import { AuthHandler, Gui, imperative, ProfilesCache, ZoweVsCodeExtension } from "@zowe/zowe-explorer-api"; +import { + createAltTypeIProfile, + createInstanceOfProfile, + createIProfile, + createISession, + createValidIProfile, +} from "../../__mocks__/mockCreators/shared"; import { MockedProperty } from "../../__mocks__/mockUtils"; import { Constants } from "../../../src/configuration/Constants"; import { ZoweLogger } from "../../../src/tools/ZoweLogger"; @@ -26,6 +32,7 @@ import { ProfilesConvertStatus, ProfilesUtils } from "../../../src/utils/Profile import { AuthUtils } from "../../../src/utils/AuthUtils"; import { ZoweLocalStorage } from "../../../src/tools/ZoweLocalStorage"; import { Definitions } from "../../../src/configuration/Definitions"; +import { createDatasetSessionNode } from "../../__mocks__/mockCreators/datasets"; jest.mock("../../../src/tools/ZoweLogger"); jest.mock("fs"); @@ -145,7 +152,7 @@ describe("ProfilesUtils unit tests", () => { expect(openConfigForMissingHostnameMock).toHaveBeenCalled(); }); - it("should handle error for invalid credentials and prompt for authentication", async () => { + it("should handle error for invalid credentials and prompt for authentication - credentials entered", async () => { const errorDetails = new imperative.ImperativeError({ msg: "Invalid credentials", errorCode: 401 as unknown as string, @@ -154,10 +161,14 @@ describe("ProfilesUtils unit tests", () => { const scenario = "Task failed successfully"; const showMessageSpy = jest.spyOn(Gui, "errorMessage").mockImplementation(() => Promise.resolve("Update Credentials")); const promptCredsSpy = jest.fn(); - const profile = { type: "zosmf" } as any; + const ssoLoginSpy = jest.fn(); + const profile = { name: "lpar.zosmf", type: "zosmf" } as any; + // disable locking mechanism for this test, will be tested in separate test cases + Object.defineProperty(Constants, "PROFILES_CACHE", { value: { promptCredentials: promptCredsSpy, + ssoLogin: ssoLoginSpy, getProfileInfo: profileInfoMock, getLoadedProfConfig: () => profile, getDefaultProfile: () => ({}), @@ -168,9 +179,11 @@ describe("ProfilesUtils unit tests", () => { await AuthUtils.errorHandling(errorDetails, { profile, scenario }); expect(showMessageSpy).toHaveBeenCalledTimes(1); expect(promptCredsSpy).toHaveBeenCalledTimes(1); + expect(ssoLoginSpy).not.toHaveBeenCalled(); showMessageSpy.mockClear(); promptCredsSpy.mockClear(); }); + it("should handle token error and proceed to login", async () => { const errorDetails = new imperative.ImperativeError({ msg: "Invalid credentials", @@ -181,6 +194,7 @@ describe("ProfilesUtils unit tests", () => { const showErrorSpy = jest.spyOn(Gui, "errorMessage"); const showMessageSpy = jest.spyOn(Gui, "showMessage").mockImplementation(() => Promise.resolve("Log in to Authentication Service")); const ssoLoginSpy = jest.fn(); + const promptCredentialsSpy = jest.fn(); const profile = { type: "zosmf" } as any; Object.defineProperty(Constants, "PROFILES_CACHE", { value: { @@ -189,6 +203,7 @@ describe("ProfilesUtils unit tests", () => { getDefaultProfile: () => ({}), getSecurePropsForProfile: () => ["tokenValue"], ssoLogin: ssoLoginSpy, + promptCredentials: promptCredentialsSpy, }, configurable: true, }); @@ -196,6 +211,7 @@ describe("ProfilesUtils unit tests", () => { expect(showMessageSpy).toHaveBeenCalledTimes(1); expect(ssoLoginSpy).toHaveBeenCalledTimes(1); expect(showErrorSpy).not.toHaveBeenCalled(); + expect(promptCredentialsSpy).not.toHaveBeenCalled(); showErrorSpy.mockClear(); showMessageSpy.mockClear(); ssoLoginSpy.mockClear(); @@ -216,10 +232,12 @@ describe("ProfilesUtils unit tests", () => { const showErrorSpy = jest.spyOn(Gui, "errorMessage").mockResolvedValue(undefined); const showMsgSpy = jest.spyOn(Gui, "showMessage"); const promptCredentialsSpy = jest.fn(); + const ssoLogin = jest.fn(); const profile = { type: "zosmf" } as any; Object.defineProperty(Constants, "PROFILES_CACHE", { value: { promptCredentials: promptCredentialsSpy, + ssoLogin, getProfileInfo: profileInfoMock, getLoadedProfConfig: () => profile, getDefaultProfile: () => ({}), @@ -230,6 +248,7 @@ describe("ProfilesUtils unit tests", () => { await AuthUtils.errorHandling(errorDetails, { profile, scenario: moreInfo }); expect(showErrorSpy).toHaveBeenCalledTimes(1); expect(promptCredentialsSpy).not.toHaveBeenCalled(); + expect(ssoLogin).not.toHaveBeenCalled(); expect(showMsgSpy).not.toHaveBeenCalledWith("Operation Cancelled"); showErrorSpy.mockClear(); showMsgSpy.mockClear(); @@ -366,6 +385,23 @@ describe("ProfilesUtils unit tests", () => { expect(getProfileInfoSpy).toHaveBeenCalled(); }); + it("calls unlockProfile once credentials are provided", async () => { + const mockProfileInstance = new Profiles(imperative.Logger.getAppLogger()); + const promptCredentialsProfilesMock = jest.spyOn(mockProfileInstance, "promptCredentials").mockResolvedValueOnce(["someusername", "pw"]); + const updateCachedProfileMock = jest.spyOn(mockProfileInstance, "updateCachedProfile").mockResolvedValueOnce(undefined); + const profile = createIProfile(); + Object.defineProperty(Constants, "PROFILES_CACHE", { value: mockProfileInstance, configurable: true }); + const unlockProfileSpy = jest.spyOn(AuthHandler, "unlockProfile"); + const mockNode = createDatasetSessionNode(createISession(), profile); + await ProfilesUtils.promptCredentials(mockNode); + expect(promptCredentialsProfilesMock).toHaveBeenCalledTimes(1); + expect(promptCredentialsProfilesMock).toHaveBeenCalledWith(profile, true); + expect(unlockProfileSpy).toHaveBeenCalledTimes(1); + expect(unlockProfileSpy).toHaveBeenCalledWith(profile, true); + expect(updateCachedProfileMock).toHaveBeenCalledTimes(1); + expect(updateCachedProfileMock).toHaveBeenCalledWith(profile, mockNode); + }); + it("shows an error message if the profile input is undefined", async () => { const mockProfileInstance = new Profiles(imperative.Logger.getAppLogger()); jest.spyOn(ProfilesCache.prototype, "getProfileInfo").mockResolvedValue(prof as unknown as imperative.ProfileInfo); @@ -456,6 +492,7 @@ describe("ProfilesUtils unit tests", () => { const updCredsMock = jest.spyOn(Constants.PROFILES_CACHE, "promptCredentials").mockResolvedValueOnce(["test", "test"]); await ProfilesUtils.promptCredentials({ getProfile: () => testConfig, + setProfileToChoice: jest.fn(), } as any); expect(updCredsMock).toHaveBeenCalled(); expect(Gui.showMessage).toHaveBeenCalledWith("Credentials for testConfig were successfully updated"); diff --git a/packages/zowe-explorer/src/configuration/Profiles.ts b/packages/zowe-explorer/src/configuration/Profiles.ts index 52c58e804b..5fc0902a79 100644 --- a/packages/zowe-explorer/src/configuration/Profiles.ts +++ b/packages/zowe-explorer/src/configuration/Profiles.ts @@ -26,6 +26,7 @@ import { FileManagement, IRegisterClient, Types, + AuthHandler, } from "@zowe/zowe-explorer-api"; import { SettingsConfig } from "./SettingsConfig"; import { Constants } from "./Constants"; @@ -828,6 +829,7 @@ export class Profiles extends ProfilesCache { comment: ["Service profile name"], }) ); + AuthHandler.unlockProfile(serviceProfile, true); } return loginOk; } catch (err) { diff --git a/packages/zowe-explorer/src/trees/dataset/DatasetFSProvider.ts b/packages/zowe-explorer/src/trees/dataset/DatasetFSProvider.ts index 759eb43b72..36fb164dd3 100644 --- a/packages/zowe-explorer/src/trees/dataset/DatasetFSProvider.ts +++ b/packages/zowe-explorer/src/trees/dataset/DatasetFSProvider.ts @@ -27,6 +27,7 @@ import { UriFsInfo, FileEntry, ZoweExplorerApiType, + AuthHandler, } from "@zowe/zowe-explorer-api"; import { IZosFilesResponse } from "@zowe/zos-files-for-zowe-sdk"; import { Profiles } from "../../configuration/Profiles"; @@ -192,18 +193,11 @@ export class DatasetFSProvider extends BaseProvider implements vscode.FileSystem private async fetchEntriesForDataset(entry: PdsEntry, uri: vscode.Uri, uriInfo: UriFsInfo): Promise { let members: IZosFilesResponse; try { + await AuthHandler.lockProfile(uriInfo.profile); members = await ZoweExplorerApiRegister.getMvsApi(uriInfo.profile).allMembers(path.posix.basename(uri.path)); + AuthHandler.unlockProfile(uriInfo.profile); } catch (err) { - this._handleError(err, { - additionalContext: vscode.l10n.t("Failed to list dataset members"), - retry: { - fn: this.fetchEntriesForDataset.bind(this), - args: [entry, uri, uriInfo], - }, - apiType: ZoweExplorerApiType.Mvs, - profileType: uriInfo.profile?.type, - templateArgs: { profileName: uriInfo.profileName }, - }); + await AuthUtils.handleProfileAuthOnError(err, uriInfo.profile); throw err; } const pdsExtension = DatasetUtils.getExtension(entry.name); @@ -388,6 +382,7 @@ export class DatasetFSProvider extends BaseProvider implements vscode.FileSystem const metadata = dsEntry?.metadata ?? this._getInfoFromUri(uri); const profileEncoding = dsEntry?.encoding ? null : dsEntry?.metadata.profile.profile?.encoding; try { + await AuthHandler.lockProfile(metadata.profile); const resp = await ZoweExplorerApiRegister.getMvsApi(metadata.profile).getContents(metadata.dsName, { binary: dsEntry?.encoding?.kind === "binary", encoding: dsEntry?.encoding?.kind === "other" ? dsEntry?.encoding.codepage : profileEncoding, @@ -395,6 +390,7 @@ export class DatasetFSProvider extends BaseProvider implements vscode.FileSystem returnEtag: true, stream: bufBuilder, }); + AuthHandler.unlockProfile(metadata.profile); const data: Uint8Array = bufBuilder.read() ?? new Uint8Array(); //if an entry does not exist for the dataset, create it if (!dsEntry) { @@ -433,7 +429,7 @@ export class DatasetFSProvider extends BaseProvider implements vscode.FileSystem } catch (error) { //Response will error if the file is not found //Callers of fetchDatasetAtUri() do not expect it to throw an error - AuthUtils.promptForAuthError(error, metadata.profile); + await AuthUtils.handleProfileAuthOnError(error, metadata.profile); return null; } } @@ -470,6 +466,10 @@ export class DatasetFSProvider extends BaseProvider implements vscode.FileSystem } } + if (ds && ds.metadata?.profile == null) { + throw vscode.FileSystemError.FileNotFound(vscode.l10n.t("Profile does not exist for this file.")); + } + // we need to fetch the contents from the mainframe if the file hasn't been accessed yet if (!ds || (!ds.wasAccessed && !urlQuery.has("inDiff")) || isConflict) { //try and fetch its contents from remote @@ -488,12 +488,6 @@ export class DatasetFSProvider extends BaseProvider implements vscode.FileSystem throw vscode.FileSystemError.FileNotFound(uri); } - const profInfo = this._getInfoFromUri(uri); - - if (profInfo.profile == null) { - throw vscode.FileSystemError.FileNotFound(vscode.l10n.t("Profile does not exist for this file.")); - } - return isConflict ? ds.conflictData.contents : ds.data; } diff --git a/packages/zowe-explorer/src/trees/job/JobFSProvider.ts b/packages/zowe-explorer/src/trees/job/JobFSProvider.ts index 2aee3ed163..0b0c476135 100644 --- a/packages/zowe-explorer/src/trees/job/JobFSProvider.ts +++ b/packages/zowe-explorer/src/trees/job/JobFSProvider.ts @@ -27,6 +27,7 @@ import { FsAbstractUtils, ZoweExplorerApiType, ZosEncoding, + AuthHandler, } from "@zowe/zowe-explorer-api"; import { IDownloadSpoolContentParms, IJob, IJobFile } from "@zowe/zos-jobs-for-zowe-sdk"; import { Profiles } from "../../configuration/Profiles"; @@ -207,6 +208,7 @@ export class JobFSProvider extends BaseProvider implements vscode.FileSystemProv const bufBuilder = new BufferBuilder(); const jesApi = ZoweExplorerApiRegister.getJesApi(spoolEntry.metadata.profile); + await AuthHandler.lockProfile(spoolEntry.metadata.profile); try { if (jesApi.downloadSingleSpool) { const spoolDownloadObject: IDownloadSpoolContentParms = { @@ -227,9 +229,10 @@ export class JobFSProvider extends BaseProvider implements vscode.FileSystemProv bufBuilder.write(await jesApi.getSpoolContentById(jobEntry.job.jobname, jobEntry.job.jobid, spoolEntry.spool.id)); } } catch (err) { - AuthUtils.promptForAuthError(err, spoolEntry.metadata.profile); + await AuthUtils.handleProfileAuthOnError(err, spoolEntry.metadata.profile); throw err; } + AuthHandler.unlockProfile(spoolEntry.metadata.profile); this._fireSoon({ type: vscode.FileChangeType.Changed, uri }); spoolEntry.data = bufBuilder.read() ?? new Uint8Array(); diff --git a/packages/zowe-explorer/src/trees/shared/SharedInit.ts b/packages/zowe-explorer/src/trees/shared/SharedInit.ts index 539aae7800..8999928f5b 100644 --- a/packages/zowe-explorer/src/trees/shared/SharedInit.ts +++ b/packages/zowe-explorer/src/trees/shared/SharedInit.ts @@ -141,6 +141,23 @@ export class SharedInit { }) ); + context.subscriptions.push( + ZoweVsCodeExtension.onProfileUpdated(async (profile) => { + const providers = Object.values(SharedTreeProviders.providers); + for (const provider of providers) { + try { + const node = (await provider.getChildren()).find((n) => n.label === profile?.name); + node?.setProfileToChoice?.(profile); + } catch (err) { + if (err instanceof Error) { + ZoweLogger.error(err.message); + } + return; + } + } + }) + ); + if (providers.ds || providers.uss) { context.subscriptions.push( vscode.commands.registerCommand("zowe.openRecentMember", () => SharedActions.openRecentMemberPrompt(providers.ds, providers.uss)) diff --git a/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts b/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts index 087316acf7..795d8110a4 100644 --- a/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts +++ b/packages/zowe-explorer/src/trees/uss/UssFSProvider.ts @@ -24,6 +24,7 @@ import { ZoweScheme, UriFsInfo, ZoweExplorerApiType, + AuthHandler, } from "@zowe/zowe-explorer-api"; import { IZosFilesResponse } from "@zowe/zos-files-for-zowe-sdk"; import { USSFileStructure } from "./USSFileStructure"; @@ -279,6 +280,7 @@ export class UssFSProvider extends BaseProvider implements vscode.FileSystemProv try { await this.autoDetectEncoding(file as UssFile); const profileEncoding = file.encoding ? null : file.metadata.profile.profile?.encoding; + await AuthHandler.lockProfile(metadata.profile); resp = await ZoweExplorerApiRegister.getUssApi(metadata.profile).getContents(filePath, { binary: file.encoding?.kind === "binary", encoding: file.encoding?.kind === "other" ? file.encoding.codepage : profileEncoding, @@ -286,11 +288,12 @@ export class UssFSProvider extends BaseProvider implements vscode.FileSystemProv returnEtag: true, stream: bufBuilder, }); + AuthHandler.unlockProfile(metadata.profile); } catch (err) { if (err instanceof Error) { ZoweLogger.error(err.message); } - AuthUtils.promptForAuthError(err, metadata.profile); + await AuthUtils.handleProfileAuthOnError(err, metadata.profile); return; } @@ -321,7 +324,7 @@ export class UssFSProvider extends BaseProvider implements vscode.FileSystemProv if (entry.encoding !== undefined) { return; } - + await AuthHandler.lockProfile(entry.metadata.profile); const ussApi = ZoweExplorerApiRegister.getUssApi(entry.metadata.profile); if (ussApi.getTag != null) { const taggedEncoding = await ussApi.getTag(entry.metadata.path); @@ -334,6 +337,7 @@ export class UssFSProvider extends BaseProvider implements vscode.FileSystemProv const isBinary = await ussApi.isFileTagBinOrAscii(entry.metadata.path); entry.encoding = isBinary ? { kind: "binary" } : undefined; } + AuthHandler.unlockProfile(entry.metadata.profile); } public async fetchEncodingForUri(uri: vscode.Uri): Promise { diff --git a/packages/zowe-explorer/src/utils/AuthUtils.ts b/packages/zowe-explorer/src/utils/AuthUtils.ts index 990a06b0dc..595e9b3b30 100644 --- a/packages/zowe-explorer/src/utils/AuthUtils.ts +++ b/packages/zowe-explorer/src/utils/AuthUtils.ts @@ -11,7 +11,7 @@ import * as util from "util"; import * as vscode from "vscode"; -import { imperative, Gui, MainframeInteraction, IZoweTreeNode, ErrorCorrelator, ZoweExplorerApiType, CorrelatedError } from "@zowe/zowe-explorer-api"; +import { imperative, Gui, MainframeInteraction, IZoweTreeNode, ErrorCorrelator, ZoweExplorerApiType, AuthHandler } from "@zowe/zowe-explorer-api"; import { Constants } from "../configuration/Constants"; import { ZoweLogger } from "../tools/ZoweLogger"; import { SharedTreeProviders } from "../trees/shared/SharedTreeProviders"; @@ -24,52 +24,43 @@ interface ErrorContext { } export class AuthUtils { - public static async promptForAuthentication( - imperativeError: imperative.ImperativeError, - profile: imperative.IProfileLoaded, - correlation?: CorrelatedError - ): Promise { - if (imperativeError.mDetails.additionalDetails) { - const tokenError: string = imperativeError.mDetails.additionalDetails; - const isTokenAuth = await AuthUtils.isUsingTokenAuth(profile.name); - - if (tokenError.includes("Token is not valid or expired.") || isTokenAuth) { - const message = vscode.l10n.t("Log in to Authentication Service"); - const userResp = await Gui.showMessage(correlation?.message ?? imperativeError.message, { - items: [message], - vsCodeOpts: { modal: true }, - }); - return userResp === message ? Constants.PROFILES_CACHE.ssoLogin(null, profile.name) : false; - } - } - const checkCredsButton = vscode.l10n.t("Update Credentials"); - const creds = await Gui.errorMessage(correlation?.message ?? imperativeError.message, { - items: [checkCredsButton], - vsCodeOpts: { modal: true }, - }).then(async (selection) => { - if (selection !== checkCredsButton) { - return; - } - return Constants.PROFILES_CACHE.promptCredentials(profile, true); - }); - return creds != null ? true : false; - } - - public static promptForAuthError(err: Error, profile: imperative.IProfileLoaded): void { + /** + * Locks the profile if an authentication error has occurred (prevents further requests in filesystem until unlocked). + * If the error is not an authentication error, the profile is unlocked for further use. + * + * @param err {Error} The error that occurred + * @param profile {imperative.IProfileLoaded} The profile used when the error occurred + */ + public static async handleProfileAuthOnError(err: Error, profile: imperative.IProfileLoaded): Promise { if ( err instanceof imperative.ImperativeError && profile != null && (Number(err.errorCode) === imperative.RestConstants.HTTP_STATUS_401 || err.message.includes("All configured authentication methods failed")) ) { - const correlation = ErrorCorrelator.getInstance().correlateError(ZoweExplorerApiType.All, err, { + // In the case of an authentication error, find a more user-friendly error message if available. + const errorCorrelation = ErrorCorrelator.getInstance().correlateError(ZoweExplorerApiType.All, err, { templateArgs: { profileName: profile.name, }, }); - void AuthUtils.promptForAuthentication(err, profile, correlation).catch( - (error) => error instanceof Error && ZoweLogger.error(error.message) - ); + + const authOpts = { + authMethods: Constants.PROFILES_CACHE, + imperativeError: err, + isUsingTokenAuth: await AuthUtils.isUsingTokenAuth(profile.name), + errorCorrelation, + }; + // If the profile is already locked, prompt the user to re-authenticate. + if (AuthHandler.isProfileLocked(profile)) { + await AuthHandler.promptForAuthentication(profile, authOpts); + } else { + // Lock the profile and prompt the user for authentication by providing login/credential prompt options. + await AuthHandler.lockProfile(profile, authOpts); + } + } else if (AuthHandler.isProfileLocked(profile)) { + // Error doesn't satisfy criteria to continue holding the lock. Unlock the profile to allow further use + AuthHandler.unlockProfile(profile); } } @@ -97,7 +88,7 @@ export class AuthUtils { ZoweLogger.error(`${errorDetails.toString()}\n` + util.inspect({ errorDetails, ...{ ...moreInfo, profile: undefined } }, { depth: null })); const profile = typeof moreInfo.profile === "string" ? Constants.PROFILES_CACHE.loadNamedProfile(moreInfo.profile) : moreInfo?.profile; - const correlation = ErrorCorrelator.getInstance().correlateError(moreInfo?.apiType ?? ZoweExplorerApiType.All, errorDetails, { + const errorCorrelation = ErrorCorrelator.getInstance().correlateError(moreInfo?.apiType ?? ZoweExplorerApiType.All, errorDetails, { profileType: profile?.type, ...Object.keys(moreInfo).reduce((all, k) => (typeof moreInfo[k] === "string" ? { ...all, [k]: moreInfo[k] } : all), {}), templateArgs: { profileName: profile?.name ?? "", ...moreInfo?.templateArgs }, @@ -114,14 +105,22 @@ export class AuthUtils { (httpErrorCode === imperative.RestConstants.HTTP_STATUS_401 || imperativeError.message.includes("All configured authentication methods failed")) ) { - return AuthUtils.promptForAuthentication(imperativeError, profile, correlation); + if (!AuthHandler.isProfileLocked(profile)) { + await AuthHandler.lockProfile(profile); + } + return await AuthHandler.promptForAuthentication(profile, { + authMethods: Constants.PROFILES_CACHE, + imperativeError, + isUsingTokenAuth: await AuthUtils.isUsingTokenAuth(profile.name), + errorCorrelation, + }); } } if (errorDetails.toString().includes("Could not find profile")) { return false; } - void ErrorCorrelator.getInstance().displayCorrelatedError(correlation, { templateArgs: { profileName: profile?.name ?? "" } }); + void ErrorCorrelator.getInstance().displayCorrelatedError(errorCorrelation, { templateArgs: { profileName: profile?.name ?? "" } }); return false; } @@ -195,13 +194,9 @@ export class AuthUtils { * @returns {Promise} a boolean representing whether token based auth is being used or not */ public static async isUsingTokenAuth(profileName: string): Promise { - const secureProfileProps = await Constants.PROFILES_CACHE.getSecurePropsForProfile(profileName); - const profileUsesBasicAuth = secureProfileProps.includes("user") && secureProfileProps.includes("password"); - if (secureProfileProps.includes("tokenValue")) { - return secureProfileProps.includes("tokenValue") && !profileUsesBasicAuth; - } + const secureProps = await Constants.PROFILES_CACHE.getSecurePropsForProfile(profileName); const baseProfile = Constants.PROFILES_CACHE.getDefaultProfile("base"); - const secureBaseProfileProps = await Constants.PROFILES_CACHE.getSecurePropsForProfile(baseProfile?.name); - return secureBaseProfileProps.includes("tokenValue") && !profileUsesBasicAuth; + const baseSecureProps = await Constants.PROFILES_CACHE.getSecurePropsForProfile(baseProfile?.name); + return AuthHandler.isUsingTokenAuth(secureProps, baseSecureProps); } } diff --git a/packages/zowe-explorer/src/utils/CertificateWizard.ts b/packages/zowe-explorer/src/utils/CertificateWizard.ts index 265bf9d674..fa0adb6750 100644 --- a/packages/zowe-explorer/src/utils/CertificateWizard.ts +++ b/packages/zowe-explorer/src/utils/CertificateWizard.ts @@ -9,7 +9,7 @@ * */ -import { Gui, WebView } from "@zowe/zowe-explorer-api"; +import { DeferredPromise, Gui, WebView } from "@zowe/zowe-explorer-api"; import * as vscode from "vscode"; import { ZoweLogger } from "../tools/ZoweLogger"; import * as fs from "fs"; @@ -20,19 +20,6 @@ export type CertWizardOpts = { dialogOpts?: vscode.OpenDialogOptions; }; -class DeferredPromise { - public promise: Promise; - public resolve: (value: T | PromiseLike) => void; - public reject: (reason?: any) => void; - - public constructor() { - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - } -} - const allFiles = vscode.l10n.t("All Files"); const userDismissed = vscode.l10n.t("User dismissed the Certificate Wizard."); diff --git a/packages/zowe-explorer/src/utils/ProfilesUtils.ts b/packages/zowe-explorer/src/utils/ProfilesUtils.ts index c0d2ece423..3be73047f0 100644 --- a/packages/zowe-explorer/src/utils/ProfilesUtils.ts +++ b/packages/zowe-explorer/src/utils/ProfilesUtils.ts @@ -12,7 +12,16 @@ import * as vscode from "vscode"; import * as path from "path"; import * as fs from "fs"; -import { IZoweTreeNode, ZoweTreeNode, FileManagement, Gui, ProfilesCache, imperative, ZoweVsCodeExtension } from "@zowe/zowe-explorer-api"; +import { + IZoweTreeNode, + ZoweTreeNode, + FileManagement, + Gui, + ProfilesCache, + imperative, + ZoweVsCodeExtension, + AuthHandler, +} from "@zowe/zowe-explorer-api"; import { Constants } from "../configuration/Constants"; import { SettingsConfig } from "../configuration/SettingsConfig"; import { ZoweLogger } from "../tools/ZoweLogger"; @@ -452,6 +461,10 @@ export class ProfilesUtils { args: [typeof profile === "string" ? profile : profile.name], comment: ["Profile name"], }); + AuthHandler.unlockProfile(profile, true); + if (typeof profile !== "string") { + await Constants.PROFILES_CACHE.updateCachedProfile(profile, node); + } ZoweLogger.info(successMsg); Gui.showMessage(successMsg); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d34252a9d..4f86cd3c09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ importers: '@zowe/zosmf-for-zowe-sdk': specifier: ^8.8.3 version: 8.8.3(@zowe/core-for-zowe-sdk@8.8.3)(@zowe/imperative@8.8.3) + async-mutex: + specifier: ^0.5.0 + version: 0.5.0 deep-object-diff: specifier: ^1.1.9 version: 1.1.9 @@ -4311,6 +4314,12 @@ packages: engines: {node: '>=0.12.0'} dev: true + /async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + dependencies: + tslib: 2.6.2 + dev: false + /async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} dev: true