Skip to content
Draft
1 change: 1 addition & 0 deletions packages/zowe-explorer-api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ All notable changes to the "zowe-explorer-api" extension will be documented in t
- Introduced a `FeatureFlag` class to manage experimental features via toggleable flags. [#3963](https://github.com/zowe/zowe-explorer-vscode/pull/3963)
- Enhanced `DataSetAttributesProvider` to pass raw API response attributes to extenders via the `DsInfo` context object. Extenders can now access the `attributes` field in the context to retrieve data set information without making additional API calls. [#3927](https://github.com/zowe/zowe-explorer-vscode/issues/3927)
- Added `Name` and `DateCreated` to `Sorting.DatasetFilterOpts`. [#4075](https://github.com/zowe/zowe-explorer-vscode/pull/4075)
- Added the `changePassword` API which allows the user to update their password on the remote system. [#4212](https://github.com/zowe/zowe-explorer-vscode/pull/4212)

### Bug fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,33 @@ describe("CommonApi", () => {
expect(session).not.toBeUndefined();
});
});

describe("changePassword", () => {
it("should call ZosmfChangePassword.changePassword and resolve on success", async () => {
const commonApi = new ZoweExplorerZosmf.CommonApi(loadedProfile);
const changePasswordSpy = jest.spyOn(zosmf.ZosmfChangePassword, "changePassword").mockResolvedValue({
success: true,
returnCode: 0,
reasonCode: 0,
message: "Password changed successfully",
});
await expect(commonApi.changePassword(fakeSession, "newPass123")).resolves.toBeUndefined();
expect(changePasswordSpy).toHaveBeenCalledWith(fakeSession, "newPass123");
changePasswordSpy.mockRestore();
});

it("should throw when the response indicates failure", async () => {
const commonApi = new ZoweExplorerZosmf.CommonApi(loadedProfile);
const changePasswordSpy = jest.spyOn(zosmf.ZosmfChangePassword, "changePassword").mockResolvedValue({
success: false,
returnCode: 8,
reasonCode: 2,
message: "Change password failed.",
});
await expect(commonApi.changePassword(fakeSession, "bad")).rejects.toThrow("Change password failed.");
changePasswordSpy.mockRestore();
});
});
});

describe("ZosmfUssApi", () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/zowe-explorer-api/src/extend/MainframeInteraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ export namespace MainframeInteraction {
* @returns {string} the token type name as defined by a CLI plugin that implements the profile.
*/
getTokenTypeName?(): string;

/**
* Change the password for a user on the remote system.
*
* Unlike "Update Credentials", which only updates the locally stored
* password, this operation contacts the server to change the password
* on the mainframe and then updates the local credential store.
Comment on lines +87 to +89

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this function expected to update the local credential store? That seems a bit unconventional as ZE usually manages changes to credentials in the secure store.
I'd recommend adjusting this comment if ZE is handling the local credential changes.

*
* @param {imperative.Session} session The session containing the current (old) credentials.
* @param {string} newPassword The new password to set on the remote system.
* @returns {Promise<void>} Resolves when the password has been changed.
*/
changePassword?(session: imperative.Session, newPassword: string): Promise<void>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ import { IDataSetCount } from "../dataset/IDataSetCount";
public logout(session: imperative.Session): Promise<void> {
return Logout.apimlLogout(session);
}

public async changePassword(session: imperative.Session, newPassword: string): Promise<void> {
const response = await zosmf.ZosmfChangePassword.changePassword(session, newPassword);
if (!response.success) {
throw new Error(response.message ?? "Password change failed");
}
Comment on lines +113 to +116

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not opposed to this pattern, but wondering if it would be more consistent to just return the RHS of response directly (as we do w/ other APIs) and then check for response.success where the API is used?

}
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/zowe-explorer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All notable changes to the "vscode-extension-for-zowe" extension will be documen
- Added a new VS Code toggle setting that allows users to show or hide hidden files in the Unix System Service (USS) tree view.[#3912](https://github.com/zowe/zowe-explorer-vscode/issues/3912)
- Added a new VS Code setting that allows users to limit the number of concurrent REST requests sent to z/OSMF. [#4130](https://github.com/zowe/zowe-explorer-vscode/pull/4130)
- Added support for downloading data sets, data set members, USS files, and USS directories. [#3843](https://github.com/zowe/zowe-explorer-vscode/pull/3843)
- Added support for changing password on the remote system, which then also updates locally stored password. [#4212](https://github.com/zowe/zowe-explorer-vscode/pull/4212)

### Bug fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ async function createGlobalMocks() {
"zowe.displayReleaseNotes",
"zowe.promptCredentials",
"zowe.profileManagement",
"zowe.changePassword",
"zowe.updateSchema",
"zowe.diff.useLocalContent",
"zowe.diff.useRemoteContent",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@ import * as fs from "fs";
import * as path from "path";
import * as util from "util";
import * as vscode from "vscode";
import { AuthHandler, ErrorCorrelator, Gui, imperative, ProfilesCache, ZoweVsCodeExtension, FsAbstractUtils } from "@zowe/zowe-explorer-api";
import {
AuthHandler,
ErrorCorrelator,
Gui,
IZoweTreeNode,
imperative,
ProfilesCache,
ZoweVsCodeExtension,
FsAbstractUtils,
} from "@zowe/zowe-explorer-api";
import {
createAltTypeIProfile,
createInstanceOfProfile,
Expand Down Expand Up @@ -1586,4 +1595,203 @@ describe("ProfilesUtils unit tests", () => {
expect(ProfilesUtils.hasNoAuthType(session, profile)).toBeTruthy();
});
});

describe("changePassword", () => {
const fakeProfile: imperative.IProfileLoaded = {
name: "testProfile",
type: "zosmf",
message: "",
failNotFound: false,
profile: { user: "IBMUSER", password: "oldPass1" },
};
const fakeSession = {
ISession: { password: "oldPass1" },
} as unknown as imperative.Session;

function createChangePasswordMocks() {
const mockNode = {
getProfile: jest.fn().mockReturnValue(fakeProfile),
} as unknown as IZoweTreeNode;

const changePasswordFn = jest.fn().mockResolvedValue(undefined);
const mockCommonApi = {
changePassword: changePasswordFn,
getSession: jest.fn().mockReturnValue(fakeSession),
};

const mockApiRegister = {
getCommonApi: jest.fn().mockReturnValue(mockCommonApi),
};
ProfilesUtils.setApiRegister(mockApiRegister as any);

const errorMessageSpy = jest.spyOn(Gui, "errorMessage").mockResolvedValue(undefined);
const infoMessageSpy = jest.spyOn(Gui, "infoMessage").mockResolvedValue(undefined);
const warningMessageSpy = jest.spyOn(Gui, "warningMessage").mockResolvedValue(undefined);
const showMessageSpy = jest.spyOn(Gui, "showMessage").mockResolvedValue(undefined);
const showInputBoxSpy = jest.spyOn(Gui, "showInputBox");

return {
mockNode,
mockCommonApi,
mockApiRegister,
changePasswordFn,
errorMessageSpy,
infoMessageSpy,
warningMessageSpy,
showMessageSpy,
showInputBoxSpy,
};
}

afterEach(() => {
jest.restoreAllMocks();
});

it("should show error if node has no profile", async () => {
const mocks = createChangePasswordMocks();
const nodeWithoutProfile = { getProfile: jest.fn().mockReturnValue(null) } as unknown as IZoweTreeNode;
await ProfilesUtils.changePassword(nodeWithoutProfile);
expect(mocks.errorMessageSpy).toHaveBeenCalledWith("No profile found for the selected node.");
});

it("should show error if no API is found for the profile", async () => {
const mocks = createChangePasswordMocks();
ProfilesUtils.setApiRegister({
getCommonApi: jest.fn().mockImplementation(() => {
throw new Error("No API");
}),
} as any);
await ProfilesUtils.changePassword(mocks.mockNode);
expect(mocks.errorMessageSpy).toHaveBeenCalledWith("No API found for the selected profile.");
});

it("should show error if changePassword is not supported by the API", async () => {
const mocks = createChangePasswordMocks();
ProfilesUtils.setApiRegister({
getCommonApi: jest.fn().mockReturnValue({
getSession: jest.fn().mockReturnValue(fakeSession),
}),
} as any);
await ProfilesUtils.changePassword(mocks.mockNode);
expect(mocks.errorMessageSpy).toHaveBeenCalledWith(expect.stringContaining("Change Password is not supported"));
});

it("should show error if unable to create a session", async () => {
const mocks = createChangePasswordMocks();
mocks.mockCommonApi.getSession = jest.fn().mockReturnValue(undefined);
ProfilesUtils.setApiRegister({
getCommonApi: jest.fn().mockReturnValue(mocks.mockCommonApi),
} as any);
await ProfilesUtils.changePassword(mocks.mockNode);
expect(mocks.errorMessageSpy).toHaveBeenCalledWith("Unable to create a session for the selected profile.");
});

it("should cancel if old password input is dismissed", async () => {
const mocks = createChangePasswordMocks();
mocks.showInputBoxSpy.mockResolvedValueOnce(undefined); // old password cancelled
await ProfilesUtils.changePassword(mocks.mockNode);
expect(mocks.infoMessageSpy).toHaveBeenCalledWith("Operation cancelled");
expect(mocks.changePasswordFn).not.toHaveBeenCalled();
});

it("should show error if old password does not match stored credentials", async () => {
const mocks = createChangePasswordMocks();
mocks.showInputBoxSpy.mockResolvedValueOnce("wrongPassword"); // old password mismatch
await ProfilesUtils.changePassword(mocks.mockNode);
expect(mocks.errorMessageSpy).toHaveBeenCalledWith("Current password does not match the stored credentials. Password was not changed.");
expect(mocks.changePasswordFn).not.toHaveBeenCalled();
});

it("should cancel if new password input is dismissed", async () => {
const mocks = createChangePasswordMocks();
mocks.showInputBoxSpy
.mockResolvedValueOnce("oldPass1") // old password
.mockResolvedValueOnce(undefined); // new password cancelled
await ProfilesUtils.changePassword(mocks.mockNode);
expect(mocks.infoMessageSpy).toHaveBeenCalledWith("Operation cancelled");
expect(mocks.changePasswordFn).not.toHaveBeenCalled();
});

it("should cancel if confirm password input is dismissed", async () => {
const mocks = createChangePasswordMocks();
mocks.showInputBoxSpy
.mockResolvedValueOnce("oldPass1") // old password
.mockResolvedValueOnce("newPass456") // new password
.mockResolvedValueOnce(undefined); // confirm cancelled
await ProfilesUtils.changePassword(mocks.mockNode);
expect(mocks.infoMessageSpy).toHaveBeenCalledWith("Operation cancelled");
expect(mocks.changePasswordFn).not.toHaveBeenCalled();
});

it("should show error if new password and confirm password do not match", async () => {
const mocks = createChangePasswordMocks();
mocks.showInputBoxSpy
.mockResolvedValueOnce("oldPass1") // old password
.mockResolvedValueOnce("newPass456") // new password
.mockResolvedValueOnce("differentPass"); // confirm mismatch
await ProfilesUtils.changePassword(mocks.mockNode);
expect(mocks.errorMessageSpy).toHaveBeenCalledWith("Passwords do not match. Password was not changed.");
expect(mocks.changePasswordFn).not.toHaveBeenCalled();
});

it("should show error if changePassword API call fails", async () => {
const mocks = createChangePasswordMocks();
mocks.showInputBoxSpy
.mockResolvedValueOnce("oldPass1") // old password
.mockResolvedValueOnce("newPass456") // new password
.mockResolvedValueOnce("newPass456"); // confirm
mocks.changePasswordFn.mockRejectedValueOnce(new Error("Server rejected request"));
await ProfilesUtils.changePassword(mocks.mockNode);
expect(mocks.errorMessageSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to change password: Server rejected request"));
});

it("should show warning if credentials update locally fails after server change", async () => {
const mocks = createChangePasswordMocks();
mocks.showInputBoxSpy
.mockResolvedValueOnce("oldPass1") // old password
.mockResolvedValueOnce("newPass456") // new password
.mockResolvedValueOnce("newPass456"); // confirm
Object.defineProperty(Constants, "PROFILES_CACHE", {
value: { getProfileInfo: jest.fn().mockRejectedValue(new Error("Disk write error")) },
configurable: true,
});
await ProfilesUtils.changePassword(mocks.mockNode);
expect(mocks.changePasswordFn).toHaveBeenCalledWith(fakeSession, "newPass456");
expect(mocks.warningMessageSpy).toHaveBeenCalledWith(expect.stringContaining("Password changed on the server but failed"));
});

it("should successfully change password and show success message", async () => {
const mocks = createChangePasswordMocks();
mocks.showInputBoxSpy
.mockResolvedValueOnce("oldPass1") // old password
.mockResolvedValueOnce("newPass456") // new password
.mockResolvedValueOnce("newPass456"); // confirm
const mockProfInfo = {
updateProperty: jest.fn().mockResolvedValue(undefined),
isSecured: jest.fn().mockReturnValue(true),
};
const updateCachedProfileMock = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(Constants, "PROFILES_CACHE", {
value: {
getProfileInfo: jest.fn().mockResolvedValue(mockProfInfo),
updateCachedProfile: updateCachedProfileMock,
},
configurable: true,
});
const mockTreeProvider = { refreshElement: jest.fn() } as any;
jest.spyOn(SharedTreeProviders, "getProviderForNode").mockReturnValue(mockTreeProvider);

await ProfilesUtils.changePassword(mocks.mockNode);
expect(mocks.changePasswordFn).toHaveBeenCalledWith(fakeSession, "newPass456");
expect(mockProfInfo.updateProperty).toHaveBeenCalledWith({
profileName: "testProfile",
profileType: "zosmf",
property: "password",
value: "newPass456",
setSecure: true,
});
expect(updateCachedProfileMock).toHaveBeenCalledWith(fakeProfile, mocks.mockNode);
expect(mocks.showMessageSpy).toHaveBeenCalledWith("Password for testProfile was successfully changed");
});
});
});
Loading
Loading