Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Debounce credential prompts and check profile locks in more places #3480

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f693249
refactor: check locks in dataset FS remote lookup; update AuthHandler…
traeok Feb 25, 2025
2bc8c44
refactor: handle auth prompts for more USS scenarios
traeok Feb 25, 2025
9b30aeb
refactor: use latest profile in more places in FSPs; part 2 to recent…
traeok Feb 25, 2025
f8aeab3
fix: debounce auth prompts during parallel requests
traeok Feb 25, 2025
4885d85
feat: useModal optional property for auth prompts
traeok Feb 25, 2025
0e2e471
fix(AuthHandler): use modal prompt for FSPs
traeok Feb 25, 2025
9c43211
refactor: debounce prompts using separate prompt mutexes
traeok Feb 25, 2025
d97d5c1
fix(tests): use fake timers in waitForUnlock test
traeok Feb 25, 2025
3eeb67b
refactor: unlock all profiles after vault changed
traeok Feb 25, 2025
3082d45
fix tests; update changelog
traeok Feb 25, 2025
bca7f2a
fix: await AuthHandler.shouldHandleAuthError
traeok Feb 25, 2025
9da0610
chore: update changelog
traeok Feb 25, 2025
859a088
wip: AuthHandler & AuthUtils patch coverage
traeok Feb 26, 2025
054ef4b
fix(AuthHandler): remove unnecessary branch
traeok Feb 26, 2025
b3767e3
tests: Data Set and USS profile lock tests
traeok Feb 27, 2025
5ccac6c
refactor: use ZoweLogger.warn instead of debug
traeok Feb 27, 2025
980b31c
Merge branch 'main' into fix/cred-loop-virtual-workspace
traeok Feb 27, 2025
66fe610
refactor: remove unused useModal param
traeok Feb 27, 2025
c4f4c00
chore: add ZE API changelog
traeok Feb 27, 2025
adcc1cc
tests: fix logic & remove skip on listFiles cases
traeok Feb 27, 2025
3c6b2b4
more test cases for profile lock checks
traeok Feb 27, 2025
3385bb0
Merge branch 'main' into fix/cred-loop-virtual-workspace
traeok Feb 27, 2025
81d2501
chore: fix changelog
traeok Feb 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/zowe-explorer-api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All notable changes to the "zowe-explorer-api" extension will be documented in t
### Bug fixes

- Fixes an issue where properties of the `TableViewProvider` class were not accessible when the class was extended by developers. [#3456](https://github.com/zowe/zowe-explorer-vscode/pull/3456)
- Fixed issue where the `AuthHandler.waitForUnlock` function could hang indefinitely if the profile is never unlocked. Now, as a safety measure, the function returns after a 30-second timeout. This function should be used alongside the `AuthHandler.isProfileLocked` function to verify that the profile is unlocked before making API requests. [#3480](https://github.com/zowe/zowe-explorer-vscode/pull/3480)

## `3.1.1`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,14 @@ describe("AuthHandler.enableLocksForType", () => {

describe("AuthHandler.waitForUnlock", () => {
it("calls Mutex.waitForUnlock if the profile lock is present", async () => {
// Used so that `setTimeout` can be invoked from 30sec timeout promise
jest.useFakeTimers();
const mutex = new Mutex();
const waitForUnlockMock = jest.spyOn(mutex, "waitForUnlock");
const isLockedMock = jest.spyOn(mutex, "isLocked").mockReturnValueOnce(true);
const waitForUnlockMock = jest.spyOn(mutex, "waitForUnlock").mockResolvedValueOnce(undefined);
(AuthHandler as any).profileLocks.set(TEST_PROFILE_NAME, mutex);
await AuthHandler.waitForUnlock(TEST_PROFILE_NAME);
expect(isLockedMock).toHaveBeenCalled();
expect(waitForUnlockMock).toHaveBeenCalled();
(AuthHandler as any).profileLocks.clear();
});
Expand All @@ -50,6 +54,23 @@ describe("AuthHandler.waitForUnlock", () => {
});
});

describe("AuthHandler.unlockAllProfiles", () => {
it("unlocks all profiles in the AuthHandler.profileLocks map", async () => {
const mutexAuthPrompt = new Mutex();
const mutexProfile = new Mutex();
const releaseAuthPromptMutex = jest.spyOn(mutexAuthPrompt, "release");
const releaseProfileMutex = jest.spyOn(mutexProfile, "release");
(AuthHandler as any).authPromptLocks.set(TEST_PROFILE_NAME, mutexAuthPrompt);
(AuthHandler as any).profileLocks.set(TEST_PROFILE_NAME, mutexProfile);

AuthHandler.unlockAllProfiles();
expect(releaseAuthPromptMutex).toHaveBeenCalledTimes(1);
expect(releaseProfileMutex).toHaveBeenCalledTimes(1);
(AuthHandler as any).authPromptLocks.clear();
(AuthHandler as any).profileLocks.clear();
});
});

describe("AuthHandler.isProfileLocked", () => {
it("returns true if the profile is locked", async () => {
await AuthHandler.lockProfile(TEST_PROFILE_NAME);
Expand Down Expand Up @@ -201,3 +222,12 @@ describe("AuthHandler.unlockProfile", () => {
expect(reloadWorkspaceMock).toHaveBeenCalledWith(TEST_PROFILE_NAME);
});
});

describe("AuthHandler.shouldHandleAuthError", () => {
it("returns true if a credential prompt was not yet shown to the user", async () => {
await expect(AuthHandler.shouldHandleAuthError(TEST_PROFILE_NAME)).resolves.toBe(true);
});
it("returns false if the user is currently responding to a credential prompt", async () => {
await expect(AuthHandler.shouldHandleAuthError(TEST_PROFILE_NAME)).resolves.toBe(false);
});
});
62 changes: 60 additions & 2 deletions packages/zowe-explorer-api/src/profiles/AuthHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@

export type ProfileLike = string | imperative.IProfileLoaded;
export class AuthHandler {
private static profileLocks: Map<string, Mutex> = new Map();
private static authPromptLocks = new Map<string, Mutex>();
private static profileLocks = new Map<string, Mutex>();
private static enabledProfileTypes: Set<string> = new Set(["zosmf"]);

/**
Expand Down Expand Up @@ -78,6 +79,7 @@
*/
public static unlockProfile(profile: ProfileLike, refreshResources?: boolean): void {
const profileName = AuthHandler.getProfileName(profile);
this.authPromptLocks.get(profileName)?.release();
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()) {
Expand All @@ -99,6 +101,26 @@
}
}

/**
* Determines whether to handle an authentication error for a given profile.
* This uses a mutex to prevent additional authentication prompts until the first prompt is resolved.
* @param profileName The name of the profile to check
* @returns {boolean} Whether to handle the authentication error
*/
public static async shouldHandleAuthError(profileName: string): Promise<boolean> {
if (!this.authPromptLocks.has(profileName)) {
this.authPromptLocks.set(profileName, new Mutex());
}

const mutex = this.authPromptLocks.get(profileName);
if (mutex.isLocked()) {
return false;
}

await mutex.acquire();
return true;
}

/**
* Prompts the user to authenticate over SSO or a credential prompt in the event of an error.
* @param profile The profile to authenticate
Expand Down Expand Up @@ -194,7 +216,43 @@
return;
}

return this.profileLocks.get(profileName)?.waitForUnlock();
const mutex = this.profileLocks.get(profileName);
// If the mutex isn't locked, no need to wait
if (!mutex.isLocked()) {
return;

Check warning on line 222 in packages/zowe-explorer-api/src/profiles/AuthHandler.ts

View check run for this annotation

Codecov / codecov/patch

packages/zowe-explorer-api/src/profiles/AuthHandler.ts#L222

Added line #L222 was not covered by tests
}

// Wait for the mutex to be unlocked with a timeout to prevent indefinite waiting
const timeoutMs = 30000; // 30 seconds timeout
const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(() => {
reject(new Error(`Timeout waiting for profile ${profileName} to be unlocked`));

Check warning on line 229 in packages/zowe-explorer-api/src/profiles/AuthHandler.ts

View check run for this annotation

Codecov / codecov/patch

packages/zowe-explorer-api/src/profiles/AuthHandler.ts#L229

Added line #L229 was not covered by tests
}, timeoutMs);
});

try {
await Promise.race([mutex.waitForUnlock(), timeoutPromise]);
} catch (error) {
// Log the timeout to console since we don't have access to the logger in the API
// This is acceptable as this is just a fallback for an edge case where the user did not respond to a credential prompt in time
// eslint-disable-next-line no-console
console.log(`Timeout waiting for profile ${profileName} to be unlocked`);

Check warning on line 239 in packages/zowe-explorer-api/src/profiles/AuthHandler.ts

View check run for this annotation

Codecov / codecov/patch

packages/zowe-explorer-api/src/profiles/AuthHandler.ts#L239

Added line #L239 was not covered by tests
}
}

/**
* Releases locks for all profiles.
* Used for scenarios such as the `onVaultChanged` event, where we don't know what secure values have changed,
* but we can't assume that the profile still has invalid credentials.
*/
public static unlockAllProfiles(): void {
for (const mutex of this.authPromptLocks.values()) {
mutex.release();
}

for (const mutex of this.profileLocks.values()) {
mutex.release();
}
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/zowe-explorer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ All notable changes to the "vscode-extension-for-zowe" extension will be documen
- Fixed an issue where user is unable to open a renamed sequential data set from the Data Sets tree view.. [#3345](https://github.com/zowe/zowe-explorer-vscode/issues/3345)
- Fixed missing z/OS Console icon when `Workbench > Panel: Show Labels` is set to false. [#3293](https://github.com/zowe/zowe-explorer-vscode/issues/3293)
- Fixed z/OS Console panel background colour to be in sync with the rest of the VS Code styling. [#3445](https://github.com/zowe/zowe-explorer-vscode/pull/3445)
- Fixed an issue seen with outdated profile information in the z/OS tree view data during upload and download of data set and USS files
[#3457](https://github.com/zowe/zowe-explorer-vscode/issues/3457)
- Fixed an issue seen with outdated profile information in the z/OS tree view data during upload and download of data set and USS files [#3457](https://github.com/zowe/zowe-explorer-vscode/issues/3457)
- Fixed issue where deleting too many nodes at once would cause the confirmation prompt to be oversized. [#3254](https://github.com/zowe/zowe-explorer-vscode/issues/3254)
- Fixed an issue where selecting items in table views would reset the column sort order. [#3473](https://github.com/zowe/zowe-explorer-vscode/issues/3473)
- Fixed an issue where data set migration status was incorrectly handled when the `migr` attribute was not present in the API response. [#3471](https://github.com/zowe/zowe-explorer-vscode/issues/3471)
- Fixed issue where users were prompted several times when using a profile with invalid credentials in a VS Code workspace. Now, the user is only prompted once per profile, allowing the user to enter in new credentials. [#3480](https://github.com/zowe/zowe-explorer-vscode/pull/3480)

## `3.1.1`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { Disposable, FilePermission, FileSystemError, FileType, TextEditor, Uri } from "vscode";
import { createIProfile } from "../../../__mocks__/mockCreators/shared";
import {
AuthHandler,
DirEntry,
DsEntry,
DsEntryMetadata,
Expand All @@ -30,6 +31,7 @@ import { ZoweExplorerApiRegister } from "../../../../src/extending/ZoweExplorerA
import { Profiles } from "../../../../src/configuration/Profiles";
import { AuthUtils } from "../../../../src/utils/AuthUtils";
import * as path from "path";
import { ZoweLogger } from "../../../../src/tools/ZoweLogger";
const dayjs = require("dayjs");

const testProfile = createIProfile();
Expand Down Expand Up @@ -94,6 +96,19 @@ describe("createDirectory", () => {
});

describe("readDirectory", () => {
let mockedProperty: MockedProperty;
beforeEach(() => {
mockedProperty = new MockedProperty(Profiles, "getInstance", {
value: jest.fn().mockReturnValue({
loadNamedProfile: jest.fn().mockReturnValue(testProfile),
} as any),
});
});

afterAll(() => {
mockedProperty[Symbol.dispose]();
});

describe("filter entry (session)", () => {
it("calls dataSetsMatchingPattern when reading directories if it exists", async () => {
const mockSessionEntry = { ...testEntries.session, filter: {}, metadata: { profile: testProfile, path: "/" } };
Expand Down Expand Up @@ -1240,3 +1255,105 @@ describe("rename", () => {
_lookupParentDirectoryMock.mockRestore();
});
});

describe("Expected behavior for functions w/ profile locks", () => {
let isProfileLockedMock;
let warnLoggerMock;

beforeEach(() => {
isProfileLockedMock = jest.spyOn(AuthHandler, "isProfileLocked");
warnLoggerMock = jest.spyOn(ZoweLogger, "warn").mockImplementation();
});

afterEach(() => {
isProfileLockedMock.mockRestore();
warnLoggerMock.mockRestore();
});

describe("stat", () => {
it("returns entry without API calls when profile is locked", async () => {
const fakeEntry = { ...testEntries.ps };
const lookupMock = jest.spyOn(DatasetFSProvider.instance, "lookup").mockReturnValueOnce(fakeEntry);
const getInfoForUriMock = jest.spyOn(FsAbstractUtils, "getInfoForUri").mockReturnValueOnce({
profile: testProfile,
isRoot: false,
slashAfterProfilePos: testUris.ps.path.indexOf("/", 1),
profileName: "sestest",
});

isProfileLockedMock.mockReturnValueOnce(true);
const waitForUnlockMock = jest.spyOn(AuthHandler, "waitForUnlock").mockResolvedValueOnce(undefined);

const datasetMock = jest.fn().mockResolvedValueOnce({});
const getMvsApiMock = jest.spyOn(ZoweExplorerApiRegister, "getMvsApi").mockReturnValueOnce({ dataSet: datasetMock } as any);

const result = await DatasetFSProvider.instance.stat(testUris.ps);

expect(waitForUnlockMock).toHaveBeenCalledWith(testProfile);
expect(isProfileLockedMock).toHaveBeenCalledWith(testProfile);
expect(warnLoggerMock).toHaveBeenCalledWith("[DatasetFSProvider] Profile sestest is locked, waiting for authentication");
expect(datasetMock).not.toHaveBeenCalled();
expect(result).toBe(fakeEntry);

lookupMock.mockRestore();
getInfoForUriMock.mockRestore();
waitForUnlockMock.mockRestore();
getMvsApiMock.mockRestore();
});
});

describe("fetchEntriesForProfile", () => {
it("returns early without making API calls when profile is locked", async () => {
const fakeEntry = { ...testEntries.session, entries: new Map() };
const lookupAsDirectoryMock = jest.spyOn(DatasetFSProvider.instance as any, "_lookupAsDirectory").mockReturnValueOnce(fakeEntry);
const uriInfo = { profile: testProfile };

isProfileLockedMock.mockReturnValueOnce(true);
const waitForUnlockMock = jest.spyOn(AuthHandler, "waitForUnlock").mockResolvedValueOnce(undefined);

const datasetMock = jest.fn().mockResolvedValueOnce({});
const getMvsApiMock = jest.spyOn(ZoweExplorerApiRegister, "getMvsApi").mockReturnValueOnce({ dataSet: datasetMock } as any);

const result = await (DatasetFSProvider.instance as any).fetchEntriesForProfile(testUris.session, uriInfo, "USER.*");

expect(waitForUnlockMock).toHaveBeenCalledWith(testProfile);
expect(isProfileLockedMock).toHaveBeenCalledWith(testProfile);
expect(warnLoggerMock).toHaveBeenCalledWith("[DatasetFSProvider] Profile sestest is locked, waiting for authentication");
expect(datasetMock).not.toHaveBeenCalled();
expect(result).toBe(fakeEntry);

lookupAsDirectoryMock.mockRestore();
waitForUnlockMock.mockRestore();
getMvsApiMock.mockRestore();
});
});

describe("fetchDatasetAtUri", () => {
it("returns null without making API calls when profile is locked", async () => {
const file = new DsEntry("TEST.DS", false);
file.metadata = new DsEntryMetadata({ profile: testProfile, path: "/TEST.DS" });

const lookupMock = jest.spyOn(DatasetFSProvider.instance as any, "_lookupAsFile").mockReturnValueOnce(file);
const getInfoFromUriMock = jest.spyOn(DatasetFSProvider.instance as any, "_getInfoFromUri").mockReturnValueOnce(file.metadata);

isProfileLockedMock.mockReturnValueOnce(true);
const waitForUnlockMock = jest.spyOn(AuthHandler, "waitForUnlock").mockResolvedValueOnce(undefined);

const getContentsMock = jest.fn().mockResolvedValueOnce({});
const getMvsApiMock = jest.spyOn(ZoweExplorerApiRegister, "getMvsApi").mockReturnValueOnce({ getContents: getContentsMock } as any);

const result = await DatasetFSProvider.instance.fetchDatasetAtUri(testUris.ps);

expect(waitForUnlockMock).toHaveBeenCalled();
expect(isProfileLockedMock).toHaveBeenCalled();
expect(warnLoggerMock).toHaveBeenCalledWith("[DatasetFSProvider] Profile sestest is locked, waiting for authentication");
expect(getContentsMock).not.toHaveBeenCalled();
expect(result).toBeNull();

lookupMock.mockRestore();
getInfoFromUriMock.mockRestore();
waitForUnlockMock.mockRestore();
getMvsApiMock.mockRestore();
});
});
});
Loading