-
Notifications
You must be signed in to change notification settings - Fork 93
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: Lock profiles during async operations (phase 1: filesystem) (…
…#3370) * move DeferredPromise to API Signed-off-by: Trae Yelovich <[email protected]> * wip: AuthHandler Signed-off-by: Trae Yelovich <[email protected]> * refactor: 'promptForAuthOnError' -> 'lockProfileOnAuthError' Signed-off-by: Trae Yelovich <[email protected]> * wip: reload workspaces once reauthenticated - note: may need to throttle amount of times the reload is called, if you have a workspace with hundreds of folders it will make a list request for each one Signed-off-by: Trae Yelovich <[email protected]> * wip: lock in other FS providers; fix update credentials logic Signed-off-by: Trae Yelovich <[email protected]> * wip(tests): resolve multiple failing tests Signed-off-by: Trae Yelovich <[email protected]> * tests: resolve failing ProfilesUtils cases Signed-off-by: Trae Yelovich <[email protected]> * wip: rename AuthUtils method, update FS providers Signed-off-by: Trae Yelovich <[email protected]> * refactor: Mutex class & use in AuthHandler; TSDoc - Adds a new `Mutex` class that wraps around a `DeferredPromise`. See the `@brief` for details. - Refactors the `AuthHandler` class to use a map of `Mutex` for each profile used in critical sections. - Added doc: `AuthHandler, DeferredPromise, Mutex` Signed-off-by: Trae Yelovich <[email protected]> * add reloadActiveEditorForProfile and related logic Signed-off-by: Trae Yelovich <[email protected]> * expose Mutex, fix failing tests Signed-off-by: Trae Yelovich <[email protected]> * chore: fix unused warnings in test code Signed-off-by: Trae Yelovich <[email protected]> * chore: fix lint and formatting errors - removed `no-return-await` lint rule since the extra microtask was removed in recent versions of Node, so there is now a benefit to using `return await` - add test `zowe.config.json` to prettierignore to avoid invalid JSON errors Signed-off-by: Trae Yelovich <[email protected]> * tests: DeferredPromise, Mutex Signed-off-by: Trae Yelovich <[email protected]> * chore: rename test file in API to match source file Signed-off-by: Trae Yelovich <[email protected]> * remove unused import Signed-off-by: Trae Yelovich <[email protected]> * refactor: use async-mutex instead of homemade mutex Signed-off-by: Trae Yelovich <[email protected]> * refactor: move fs helper fns, isLocked -> isProfileLocked Signed-off-by: Trae Yelovich <[email protected]> * wip: AuthHandler tests Signed-off-by: Trae Yelovich <[email protected]> * wip: patch coverage for AuthHandler, DeferredPromise Signed-off-by: Trae Yelovich <[email protected]> * refactor: lockedProfiles -> profileLocks, patch cov in ProfilesUtils Signed-off-by: Trae Yelovich <[email protected]> * tests: FileManagement functions Signed-off-by: Trae Yelovich <[email protected]> * remove unused var 'credsEntered' Signed-off-by: Trae Yelovich <[email protected]> * refactor: save decorator for phase 2 (API changes) Signed-off-by: Trae Yelovich <[email protected]> * tests: AuthHandler SSO login during auth prompt Signed-off-by: Trae Yelovich <[email protected]> * add test for basic creds, move logic for updating trees Signed-off-by: Trae Yelovich <[email protected]> * fix lint errors Signed-off-by: Trae Yelovich <[email protected]> * tests: AuthHandler.updateTreeProvidersWithProfile Signed-off-by: Trae Yelovich <[email protected]> * chore: add changelogs Signed-off-by: Trae Yelovich <[email protected]> * fix: refresh resources if updated in 'Manage Profile' option Signed-off-by: Trae Yelovich <[email protected]> * refactor: use enum for DeferredPromise status Signed-off-by: Trae Yelovich <[email protected]> * refactor: use onProfileUpdate event to update tree providers Signed-off-by: Trae Yelovich <[email protected]> * fix(AuthHandler): remove redundant includes check Signed-off-by: Trae Yelovich <[email protected]> * refactor: combine nested ifs into single expression Signed-off-by: Trae Yelovich <[email protected]> * refactor: remove return in AuthHandler.lockProfile Signed-off-by: Trae Yelovich <[email protected]> * refactor: DeferredPromiseStatus.{Fulfilled -> Resolved} Signed-off-by: Trae Yelovich <[email protected]> * refactor: Clean up auth prompt options, adjust interfaces Signed-off-by: Trae Yelovich <[email protected]> * fix: add missing prompt; return expected boolean value Signed-off-by: Trae Yelovich <[email protected]> * fix(ds): remove extra 401 message during fetch Signed-off-by: Trae Yelovich <[email protected]> --------- Signed-off-by: Trae Yelovich <[email protected]>
- Loading branch information
Showing
34 changed files
with
792 additions
and
142 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
159 changes: 159 additions & 0 deletions
159
packages/zowe-explorer-api/__tests__/__unit__/profiles/AuthHandler.unit.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
}); |
47 changes: 47 additions & 0 deletions
47
packages/zowe-explorer-api/__tests__/__unit__/utils/DeferredPromise.unit.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
}); |
111 changes: 111 additions & 0 deletions
111
packages/zowe-explorer-api/__tests__/__unit__/utils/FileManagement.unit.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Uint8Array> => { | ||
// 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(); | ||
}); | ||
}); |
32 changes: 0 additions & 32 deletions
32
packages/zowe-explorer-api/__tests__/__unit__/utils/files.unit.test.ts
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.