diff --git a/.changeset/soft-moons-lie.md b/.changeset/soft-moons-lie.md new file mode 100644 index 00000000000..9dc7b9635c3 --- /dev/null +++ b/.changeset/soft-moons-lie.md @@ -0,0 +1,6 @@ +--- +'@sap-ux/generator-adp': patch +'@sap-ux/adp-tooling': patch +--- + +feat: Prompt for credentials when not available in VSCode for adp generator \ No newline at end of file diff --git a/packages/adp-tooling/src/base/credentials.ts b/packages/adp-tooling/src/base/credentials.ts new file mode 100644 index 00000000000..134e74891e0 --- /dev/null +++ b/packages/adp-tooling/src/base/credentials.ts @@ -0,0 +1,60 @@ +import { getService, BackendSystem, BackendSystemKey, SystemType } from '@sap-ux/store'; +import type { SystemLookup } from '../source'; +import type { ToolsLogger } from '@sap-ux/logger'; +import type { ConfigAnswers } from '../types'; + +/** + * Stores system credentials securely using the @sap-ux/store service. + * Only stores credentials for ABAP environments when all required fields are provided. + * + * @param {ConfigAnswers} configAnswers - Configuration answers containing credentials and system info + * @param {SystemLookup} systemLookup - System lookup service for retrieving endpoint details + * @param {ToolsLogger} logger - Logger for informational and warning messages + * @returns {Promise} Promise that resolves when credentials are stored or operation completes + */ +export async function storeCredentials( + configAnswers: ConfigAnswers, + systemLookup: SystemLookup, + logger: ToolsLogger +): Promise { + if (!configAnswers.username || !configAnswers.password) { + return; + } + + try { + const systemEndpoint = await systemLookup.getSystemByName(configAnswers.system); + if (!systemEndpoint?.Url) { + logger.warn('Cannot store credentials: system endpoint or URL not found.'); + return; + } + + const systemService = await getService({ + entityName: 'system' + }); + + const backendSystemKey = new BackendSystemKey({ + url: systemEndpoint.Url, + client: systemEndpoint.Client + }); + + const existingSystem = await systemService.read(backendSystemKey); + + const backendSystem = new BackendSystem({ + name: configAnswers.system, + url: systemEndpoint.Url, + client: systemEndpoint.Client, + username: configAnswers.username, + password: configAnswers.password, + systemType: (systemEndpoint.SystemType as SystemType) || SystemType.AbapOnPrem, + connectionType: 'abap_catalog', + userDisplayName: configAnswers.username + }); + + await systemService.write(backendSystem, { force: !!existingSystem }); + + logger.info('System credentials have been stored securely.'); + } catch (error) { + logger.error(`Failed to store credentials: ${error instanceof Error ? error.message : String(error)}`); + logger.debug(error); + } +} diff --git a/packages/adp-tooling/src/index.ts b/packages/adp-tooling/src/index.ts index ab9a978c293..128608f3613 100644 --- a/packages/adp-tooling/src/index.ts +++ b/packages/adp-tooling/src/index.ts @@ -7,6 +7,7 @@ export * from './ui5'; export * from './base/cf'; export * from './cf'; export * from './base/helper'; +export * from './base/credentials'; export * from './base/constants'; export * from './base/project-builder'; export * from './base/abap/manifest-service'; diff --git a/packages/adp-tooling/src/source/systems.ts b/packages/adp-tooling/src/source/systems.ts index bac9c50b157..0897762e090 100644 --- a/packages/adp-tooling/src/source/systems.ts +++ b/packages/adp-tooling/src/source/systems.ts @@ -1,4 +1,4 @@ -import { getService } from '@sap-ux/store'; +import { getService, SystemType } from '@sap-ux/store'; import type { ToolsLogger } from '@sap-ux/logger'; import { isAppStudio, listDestinations } from '@sap-ux/btp-utils'; import type { BackendSystem, BackendSystemKey } from '@sap-ux/store'; @@ -30,6 +30,7 @@ export const transformBackendSystem = (system: BackendSystem): Endpoint => ({ UserDisplayName: system.userDisplayName, Scp: !!system.serviceKeys, Authentication: system.authenticationType, + SystemType: system.systemType, Credentials: { username: system.username, password: system.password @@ -81,7 +82,7 @@ export class SystemLookup { entityName: 'system' }); const backendSystems = await systemStore?.getAll(); - endpoints = backendSystems.map(transformBackendSystem); + endpoints = backendSystems.filter((system) => system.name !== undefined).map(transformBackendSystem); } return endpoints; } catch (e) { @@ -120,7 +121,12 @@ export class SystemLookup { if (isAppStudio()) { return found?.Authentication === 'NoAuthentication'; } else { - return !found; + if (!found) { + return true; + } + const isOnPrem = found.SystemType === SystemType.AbapOnPrem; + const hasMissingCredentials = !found.Credentials?.username || !found.Credentials?.password; + return isOnPrem && hasMissingCredentials; } } } diff --git a/packages/adp-tooling/src/types.ts b/packages/adp-tooling/src/types.ts index 1f2ea03200b..03e773508e4 100644 --- a/packages/adp-tooling/src/types.ts +++ b/packages/adp-tooling/src/types.ts @@ -136,6 +136,7 @@ export interface ConfigAnswers { system: string; username: string; password: string; + storeCredentials?: boolean; application: SourceApplication; fioriId?: string; ach?: string; @@ -206,6 +207,7 @@ export interface Endpoint extends Partial { Credentials?: { username?: string; password?: string }; UserDisplayName?: string; Scp?: boolean; + SystemType?: string; } export interface ChangeInboundNavigation { diff --git a/packages/adp-tooling/test/unit/base/credentials.test.ts b/packages/adp-tooling/test/unit/base/credentials.test.ts new file mode 100644 index 00000000000..dbd4c35f3bc --- /dev/null +++ b/packages/adp-tooling/test/unit/base/credentials.test.ts @@ -0,0 +1,141 @@ +import { getService, SystemType } from '@sap-ux/store'; +import { storeCredentials } from '../../../src'; +import type { SystemLookup } from '../../../src'; +import type { ToolsLogger } from '@sap-ux/logger'; + +jest.mock('@sap-ux/store'); + +describe('Credential Storage Logic', () => { + let mockSystemService: any; + let mockLogger: ToolsLogger; + let mockSystemLookup: SystemLookup; + const getServiceMock = getService as jest.Mock; + + beforeEach(() => { + mockSystemService = { + read: jest.fn(), + write: jest.fn() + }; + + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + } as any; + + mockSystemLookup = { + getSystemByName: jest.fn() + } as any; + + getServiceMock.mockResolvedValue(mockSystemService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('storeCredentials function', () => { + it('should store credentials when credentials are provided', async () => { + const configAnswers = { + system: 'SystemA', + username: 'user1', + password: 'pass1', + application: {} as any + }; + + (mockSystemLookup.getSystemByName as jest.Mock).mockResolvedValue({ + Name: 'SystemA', + Client: '010', + Url: 'https://example.com', + SystemType: 'OnPrem' + }); + + mockSystemService.read.mockResolvedValue(null); + + await storeCredentials(configAnswers, mockSystemLookup, mockLogger); + + expect(getServiceMock).toHaveBeenCalledWith({ entityName: 'system' }); + expect(mockSystemService.read).toHaveBeenCalled(); + expect(mockSystemService.write).toHaveBeenCalledWith(expect.any(Object), { force: false }); + expect(mockLogger.info).toHaveBeenCalledWith('System credentials have been stored securely.'); + }); + + it('should update existing credentials when system already exists in store', async () => { + const configAnswers = { + system: 'SystemA', + username: 'user1', + password: 'pass1', + application: {} as any + }; + + (mockSystemLookup.getSystemByName as jest.Mock).mockResolvedValue({ + Name: 'SystemA', + Client: '010', + Url: 'https://example.com', + SystemType: 'OnPrem' + }); + + mockSystemService.read.mockResolvedValue({ name: 'SystemA', url: 'https://example.com' }); + + await storeCredentials(configAnswers, mockSystemLookup, mockLogger); + + expect(mockSystemService.write).toHaveBeenCalledWith(expect.any(Object), { force: true }); + expect(mockLogger.info).toHaveBeenCalledWith('System credentials have been stored securely.'); + }); + + it('should not store credentials when password is missing', async () => { + const configAnswers = { + system: 'SystemA', + username: 'user1', + password: '', + application: {} as any + } as any; + + await storeCredentials(configAnswers, mockSystemLookup, mockLogger); + + expect(getServiceMock).not.toHaveBeenCalled(); + expect(mockSystemService.write).not.toHaveBeenCalled(); + }); + + it('should warn when system endpoint is not found', async () => { + const configAnswers = { + system: 'SystemA', + username: 'user1', + password: 'pass1', + application: {} as any + }; + + (mockSystemLookup.getSystemByName as jest.Mock).mockResolvedValue(undefined); + + await storeCredentials(configAnswers, mockSystemLookup, mockLogger); + + expect(mockLogger.warn).toHaveBeenCalledWith('Cannot store credentials: system endpoint or URL not found.'); + expect(mockSystemService.write).not.toHaveBeenCalled(); + }); + + it('should handle credential storage errors gracefully', async () => { + const configAnswers = { + system: 'SystemA', + username: 'user1', + password: 'pass1', + application: {} as any + }; + + (mockSystemLookup.getSystemByName as jest.Mock).mockResolvedValue({ + Name: 'SystemA', + Client: '010', + Url: 'https://example.com', + SystemType: 'OnPrem' + }); + + const error = new Error('Storage failed'); + mockSystemService.write.mockRejectedValue(error); + + await storeCredentials(configAnswers, mockSystemLookup, mockLogger); + + expect(mockLogger.error).toHaveBeenCalledWith('Failed to store credentials: Storage failed'); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/adp-tooling/test/unit/source/systems.test.ts b/packages/adp-tooling/test/unit/source/systems.test.ts index 913a02dfd06..eabd9130cfa 100644 --- a/packages/adp-tooling/test/unit/source/systems.test.ts +++ b/packages/adp-tooling/test/unit/source/systems.test.ts @@ -44,6 +44,16 @@ const backendSystems: BackendSystem[] = [ username: 'some-user', connectionType: 'abap_catalog', systemType: 'OnPrem' + }, + { + client: '100', + name: undefined as any, + password: 'some-pw', + url: 'undefined-name-url', + userDisplayName: 'No Name User', + username: 'no-name-user', + connectionType: 'abap_catalog', + systemType: 'AbapCloud' } ]; @@ -110,6 +120,19 @@ describe('SystemLookup', () => { expect(systemsFirstCall).toEqual(mappedBackendSystems); }); + test('should filter out systems with undefined names in VS Code', async () => { + mockIsAppStudio.mockReturnValue(false); + getServiceMock.mockResolvedValue({ + getAll: jest.fn().mockResolvedValue(backendSystems) + }); + + const systems = await sourceSystems.getSystems(); + + expect(systems).toHaveLength(1); + expect(systems[0].Name).toBe('SYS_010'); + expect(systems.every((s) => s.Name !== undefined)).toBe(true); + }); + test('should throw an error if loadSystems fails', async () => { const error = new Error('Fetch failed'); mockIsAppStudio.mockReturnValue(true); @@ -170,7 +193,7 @@ describe('SystemLookup', () => { expect(result).toBe(false); }); - test('should return false if system is found in VS Code', async () => { + test('should return false if system is found with credentials in VS Code', async () => { mockIsAppStudio.mockReturnValue(false); getServiceMock.mockResolvedValue({ getAll: jest.fn().mockResolvedValue(backendSystems) @@ -191,5 +214,110 @@ describe('SystemLookup', () => { expect(result).toBe(true); }); + + test('should return true if system is found but credentials are missing in VS Code', async () => { + mockIsAppStudio.mockReturnValue(false); + const systemWithoutCredentials: BackendSystem = { + client: '010', + name: 'SYS_NO_CREDS', + password: undefined as any, + url: 'some-url', + userDisplayName: 'some-name', + username: undefined as any, + connectionType: 'abap_catalog', + systemType: 'OnPrem' + }; + getServiceMock.mockResolvedValue({ + getAll: jest.fn().mockResolvedValue([...backendSystems, systemWithoutCredentials]) + }); + + const result = await sourceSystems.getSystemRequiresAuth('SYS_NO_CREDS'); + + expect(result).toBe(true); + }); + + test('should return false for AbapCloud system even without credentials in VS Code', async () => { + mockIsAppStudio.mockReturnValue(false); + const cloudSystem: BackendSystem = { + client: '100', + name: 'CLOUD_SYS', + password: undefined as any, + url: 'cloud-url', + userDisplayName: 'Cloud User', + username: undefined as any, + connectionType: 'abap_catalog', + systemType: 'AbapCloud' + }; + getServiceMock.mockResolvedValue({ + getAll: jest.fn().mockResolvedValue([cloudSystem]) + }); + + const result = await sourceSystems.getSystemRequiresAuth('CLOUD_SYS'); + + expect(result).toBe(false); + }); + + test('should return false for OnPrem system with both username and password', async () => { + mockIsAppStudio.mockReturnValue(false); + const systemWithCreds: BackendSystem = { + client: '010', + name: 'FULL_CREDS', + password: 'testpass', + url: 'full-creds-url', + userDisplayName: 'Full Creds User', + username: 'testuser', + connectionType: 'abap_catalog', + systemType: 'OnPrem' + }; + getServiceMock.mockResolvedValue({ + getAll: jest.fn().mockResolvedValue([systemWithCreds]) + }); + + const result = await sourceSystems.getSystemRequiresAuth('FULL_CREDS'); + + expect(result).toBe(false); + }); + + test('should return false for system with undefined SystemType but has credentials', async () => { + mockIsAppStudio.mockReturnValue(false); + const systemUndefinedType: BackendSystem = { + client: '010', + name: 'UNDEFINED_TYPE', + password: 'testpass', + url: 'undefined-type-url', + userDisplayName: 'Undefined Type User', + username: 'testuser', + connectionType: 'abap_catalog', + systemType: undefined as any + }; + getServiceMock.mockResolvedValue({ + getAll: jest.fn().mockResolvedValue([systemUndefinedType]) + }); + + const result = await sourceSystems.getSystemRequiresAuth('UNDEFINED_TYPE'); + + expect(result).toBe(false); + }); + + test('should return false for system with non-OnPrem SystemType and missing credentials', async () => { + mockIsAppStudio.mockReturnValue(false); + const nonOnPremSystem: BackendSystem = { + client: '010', + name: 'NON_ONPREM', + password: undefined as any, + url: 'non-onprem-url', + userDisplayName: 'Non OnPrem User', + username: undefined as any, + connectionType: 'abap_catalog', + systemType: 'SomeOtherType' as any + }; + getServiceMock.mockResolvedValue({ + getAll: jest.fn().mockResolvedValue([nonOnPremSystem]) + }); + + const result = await sourceSystems.getSystemRequiresAuth('NON_ONPREM'); + + expect(result).toBe(false); + }); }); }); diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 044d22afa83..f974aa1b44f 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -21,7 +21,8 @@ import { isLoggedInCf, isMtaProject, loadApps, - loadCfConfig + loadCfConfig, + storeCredentials } from '@sap-ux/adp-tooling'; import { getDefaultTargetFolder, @@ -374,6 +375,10 @@ export default class extends Generator { return; } + if (this.configAnswers.storeCredentials) { + await storeCredentials(this.configAnswers, this.systemLookup, this.logger); + } + if (this.shouldCreateExtProject) { await addExtProjectGen( { diff --git a/packages/generator-adp/src/app/questions/configuration.ts b/packages/generator-adp/src/app/questions/configuration.ts index 6044166f5c0..4452344e67b 100644 --- a/packages/generator-adp/src/app/questions/configuration.ts +++ b/packages/generator-adp/src/app/questions/configuration.ts @@ -47,6 +47,7 @@ import type { FioriIdPromptOptions, PasswordPromptOptions, ShouldCreateExtProjectPromptOptions, + StoreCredentialsPromptOptions, SystemPromptOptions, UsernamePromptOptions } from '../types'; @@ -57,11 +58,13 @@ import { showApplicationQuestion, showCredentialQuestion, showExtensionProjectQuestion, - showInternalQuestions + showInternalQuestions, + showStoreCredentialsQuestion } from './helper/conditions'; import { getExtProjectMessage } from './helper/message'; import { validateExtensibilityExtension } from './helper/validators'; import type { IMessageSeverity } from '@sap-devx/yeoman-ui-types'; +import { Severity } from '@sap-devx/yeoman-ui-types'; /** * A stateful prompter class that creates configuration questions. @@ -242,6 +245,9 @@ export class ConfigPrompter { [configPromptNames.systemValidationCli]: this.getSystemValidationPromptForCli(), [configPromptNames.username]: this.getUsernamePrompt(promptOptions?.[configPromptNames.username]), [configPromptNames.password]: this.getPasswordPrompt(promptOptions?.[configPromptNames.password]), + [configPromptNames.storeCredentials]: this.getStoreCredentialsPrompt( + promptOptions?.[configPromptNames.storeCredentials] + ), [configPromptNames.application]: this.getApplicationListPrompt( promptOptions?.[configPromptNames.application] ), @@ -366,6 +372,35 @@ export class ConfigPrompter { }; } + /** + * Creates the store credentials prompt configuration. + * + * @param {StoreCredentialsPromptOptions} _ - Optional configuration for the store credentials prompt. + * @returns The store credentials prompt as a {@link ConfigQuestion}. + */ + private getStoreCredentialsPrompt(_?: StoreCredentialsPromptOptions): ConfirmQuestion { + return { + type: 'confirm', + name: configPromptNames.storeCredentials, + message: t('prompts.storeCredentialsLabelBreadcrumb'), + default: false, + guiOptions: { + breadcrumb: t('prompts.storeCredentialsLabelBreadcrumb'), + hint: t('prompts.storeCredentialsTooltip') + }, + when: (answers: ConfigAnswers) => + showStoreCredentialsQuestion(answers, this.isLoginSuccessful, this.isAuthRequired), + additionalMessages: (input?: unknown) => { + if (input === true) { + return { + message: t('warnings.passwordStoreWarning'), + severity: Severity.warning + }; + } + } + }; + } + /** * Creates the application list prompt configuration. * @@ -565,12 +600,20 @@ export class ConfigPrompter { * @param {ConfigAnswers} answers - The configuration answers provided by the user. * @returns An error message if validation fails, or true if the system selection is valid. */ - private async validatePassword(password: string, answers: ConfigAnswers): Promise { + private async validatePassword(password: string, answers?: ConfigAnswers): Promise { const validationResult = validateEmptyString(password); if (typeof validationResult === 'string') { return validationResult; } + if (!answers) { + return true; + } + + if (!answers.system || !answers.username) { + return t('error.pleaseProvideAllRequiredData'); + } + const options = { system: answers.system, client: undefined, diff --git a/packages/generator-adp/src/app/questions/helper/conditions.ts b/packages/generator-adp/src/app/questions/helper/conditions.ts index 4c5a6d68ad5..5d66977aff0 100644 --- a/packages/generator-adp/src/app/questions/helper/conditions.ts +++ b/packages/generator-adp/src/app/questions/helper/conditions.ts @@ -111,3 +111,19 @@ export function showBusinessSolutionNameQuestion( export function shouldShowBaseAppPrompt(answers: CfServicesAnswers, isCFLoggedIn: boolean, apps: CFApp[]): boolean { return isCFLoggedIn && !!answers.businessService && !!apps.length; } + +/** + * Determines if the store credentials question should be shown. + * + * @param {ConfigAnswers} answers - The user-provided answers containing credentials. + * @param {boolean} isLoginSuccessful - A flag indicating that system login was successful. + * @param {boolean} isAuthRequired - A flag indicating whether system authentication is needed. + * @returns {boolean} True if the store credentials question should be shown. + */ +export function showStoreCredentialsQuestion( + answers: ConfigAnswers, + isLoginSuccessful: boolean, + isAuthRequired: boolean +): boolean { + return !isAppStudio() && showCredentialQuestion(answers, isAuthRequired) && isLoginSuccessful && !!answers.password; +} diff --git a/packages/generator-adp/src/app/types.ts b/packages/generator-adp/src/app/types.ts index f4b1b1e01fe..05b9983090d 100644 --- a/packages/generator-adp/src/app/types.ts +++ b/packages/generator-adp/src/app/types.ts @@ -37,6 +37,7 @@ export enum configPromptNames { systemValidationCli = 'systemValidationCli', username = 'username', password = 'password', + storeCredentials = 'storeCredentials', application = 'application', appValidationCli = 'appValidationCli', fioriId = 'fioriId', @@ -69,6 +70,10 @@ export interface PasswordPromptOptions { hide?: boolean; } +export interface StoreCredentialsPromptOptions { + hide?: boolean; +} + export interface ApplicationPromptOptions { default?: string; hide?: boolean; @@ -95,6 +100,7 @@ export type ConfigPromptOptions = Partial<{ [configPromptNames.systemValidationCli]: CliValidationPromptOptions; [configPromptNames.username]: UsernamePromptOptions; [configPromptNames.password]: PasswordPromptOptions; + [configPromptNames.storeCredentials]: StoreCredentialsPromptOptions; [configPromptNames.application]: ApplicationPromptOptions; [configPromptNames.appValidationCli]: CliValidationPromptOptions; [configPromptNames.fioriId]: FioriIdPromptOptions; diff --git a/packages/generator-adp/src/translations/generator-adp.i18n.json b/packages/generator-adp/src/translations/generator-adp.i18n.json index 1077a148828..70631d4ae09 100644 --- a/packages/generator-adp/src/translations/generator-adp.i18n.json +++ b/packages/generator-adp/src/translations/generator-adp.i18n.json @@ -27,6 +27,8 @@ "usernameTooltip": "Enter the user name for the back-end system.", "passwordLabel": "Password", "passwordTooltip": "Enter the password for the back-end system.", + "storeCredentialsTooltip": "Do you want to store the system credentials?", + "storeCredentialsLabelBreadcrumb": "Store Credentials", "applicationListLabel": "Application", "applicationListTooltip": "Select the application for which you want to create an application variant.", "fioriIdLabel": "Fiori ID", @@ -79,6 +81,9 @@ "projectNotInWorkspace": "The project: '{{- path}}' is not in the workspace. Some adaptation project tools may not work. What do you want to do?", "emptyAnnotationFile": "An empty annotation file will be created in the webapp/changes/annotations folder of your project." }, + "warnings": { + "passwordStoreWarning": "Passwords are stored in your operating system's credential manager and are protected by its security policies." + }, "error": { "selectCannotBeEmptyError": "The {{value}} has to be selected.", "writingPhase": "An error occurred in the writing phase of the adaptation project generation. To see the error, view the logs.", diff --git a/packages/generator-adp/test/app.test.ts b/packages/generator-adp/test/app.test.ts index fbac2c47dd1..1f21e82be52 100644 --- a/packages/generator-adp/test/app.test.ts +++ b/packages/generator-adp/test/app.test.ts @@ -32,6 +32,7 @@ import { isLoggedInCf, loadApps, loadCfConfig, + storeCredentials, validateUI5VersionExists } from '@sap-ux/adp-tooling'; import { @@ -46,12 +47,14 @@ import type { ToolsLogger } from '@sap-ux/logger'; import * as Logger from '@sap-ux/logger'; import type { Manifest, ManifestNamespace } from '@sap-ux/project-access'; import { getCredentialsFromStore } from '@sap-ux/system-access'; +import { getService, BackendSystem, BackendSystemKey } from '@sap-ux/store'; import type { AdpGeneratorOptions } from '../src/app'; import adpGenerator from '../src/app'; import { ConfigPrompter } from '../src/app/questions/configuration'; import { KeyUserImportPrompter } from '../src/app/questions/key-user'; import { getDefaultProjectName } from '../src/app/questions/helper/default-values'; +import { showStoreCredentialsQuestion } from '../src/app/questions/helper/conditions'; import { TargetEnv, type JsonInput, type TargetEnvAnswers } from '../src/app/types'; import { EventName } from '../src/telemetry'; import { initI18n, t } from '../src/utils/i18n'; @@ -89,7 +92,8 @@ jest.mock('../src/app/questions/helper/conditions', () => ({ ...jest.requireActual('../src/app/questions/helper/conditions'), showApplicationQuestion: jest.fn().mockReturnValue(true), showExtensionProjectQuestion: jest.fn().mockReturnValue(true), - shouldShowBaseAppPrompt: jest.fn().mockReturnValue(true) + shouldShowBaseAppPrompt: jest.fn().mockReturnValue(true), + showStoreCredentialsQuestion: jest.fn().mockReturnValue(false) })); jest.mock('@sap-ux/system-access', () => ({ @@ -97,6 +101,21 @@ jest.mock('@sap-ux/system-access', () => ({ getCredentialsFromStore: jest.fn() })); +jest.mock('@sap-ux/store', () => ({ + ...jest.requireActual('@sap-ux/store'), + getService: jest.fn(), + BackendSystem: class { + constructor(public data: any) {} + }, + BackendSystemKey: class { + constructor(public data: any) {} + }, + SystemType: { + AbapOnPrem: 'OnPrem', + AbapCloudReady: 'Cloud' + } +})); + jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), exec: jest.fn() @@ -117,7 +136,8 @@ jest.mock('@sap-ux/adp-tooling', () => ({ getModuleNames: jest.fn(), getApprouterType: jest.fn(), hasApprouter: jest.fn(), - createServices: jest.fn() + createServices: jest.fn(), + storeCredentials: jest.fn() })); jest.mock('../src/utils/deps.ts', () => ({ @@ -323,6 +343,12 @@ const mockIsInternalFeaturesSettingEnabled = isInternalFeaturesSettingEnabled as typeof isInternalFeaturesSettingEnabled >; const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction; +const getServiceMock = getService as jest.Mock; +const storeCredentialsMock = storeCredentials as jest.MockedFunction; +const mockSystemService = { + read: jest.fn(), + write: jest.fn() +}; describe('Adaptation Project Generator Integration Test', () => { jest.setTimeout(60000); @@ -349,6 +375,11 @@ describe('Adaptation Project Generator Integration Test', () => { validateUI5VersionExistsMock.mockReturnValue(true); jest.spyOn(SystemLookup.prototype, 'getSystems').mockResolvedValue(endpoints); jest.spyOn(SystemLookup.prototype, 'getSystemRequiresAuth').mockResolvedValue(false); + jest.spyOn(SystemLookup.prototype, 'getSystemByName').mockResolvedValue({ + Name: 'SystemA', + Client: '010', + Url: 'urlA' + }); getConfiguredProviderMock.mockResolvedValue(dummyProvider); execMock.mockImplementation((_: string, callback: Function) => { callback(null, { stdout: 'ok', stderr: '' }); @@ -361,6 +392,10 @@ describe('Adaptation Project Generator Integration Test', () => { getDefaultProjectNameMock.mockReturnValue('app.variant1'); getCredentialsFromStoreMock.mockResolvedValue(undefined); + getServiceMock.mockResolvedValue(mockSystemService); + mockSystemService.read.mockResolvedValue(null); + mockSystemService.write.mockResolvedValue(undefined); + isCfInstalledMock.mockResolvedValue(false); loadCfConfigMock.mockReturnValue({} as CfConfig); isLoggedInCfMock.mockResolvedValue(false); @@ -516,6 +551,7 @@ describe('Adaptation Project Generator Integration Test', () => { it('should generate an onPremise adaptation project successfully', async () => { mockIsAppStudio.mockReturnValue(false); + storeCredentialsMock.mockResolvedValue(undefined); const runContext = yeomanTest .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) @@ -525,6 +561,7 @@ describe('Adaptation Project Generator Integration Test', () => { await expect(runContext.run()).resolves.not.toThrow(); expect(executeCommandSpy).toHaveBeenCalledTimes(1); + expect(storeCredentialsMock).not.toHaveBeenCalled(); const generatedDirs = fs.readdirSync(testOutputDir); expect(generatedDirs).toContain(answers.projectName); @@ -555,6 +592,29 @@ describe('Adaptation Project Generator Integration Test', () => { ); }); + it('should store credentials when storeCredentials flag is true', async () => { + mockIsAppStudio.mockReturnValue(false); + storeCredentialsMock.mockResolvedValue(undefined); + // Mock the condition to return true for store credentials question + (showStoreCredentialsQuestion as jest.Mock).mockReturnValue(true); + + const answersWithStoreCredentials = { ...answers, storeCredentials: true }; + + const runContext = yeomanTest + .create(adpGenerator, { resolved: generatorPath }, { cwd: testOutputDir }) + .withOptions({ shouldInstallDeps: false, vscode: vscodeMock } as AdpGeneratorOptions) + .withPrompts(answersWithStoreCredentials); + + await expect(runContext.run()).resolves.not.toThrow(); + + expect(storeCredentialsMock).toHaveBeenCalledTimes(1); + + const [configAnswers, systemLookup, logger] = storeCredentialsMock.mock.calls[0]; + expect(configAnswers.storeCredentials).toBe(true); + expect(systemLookup).toBeDefined(); + expect(logger).toBeDefined(); + }); + it('should generate adaptation project with key user changes', async () => { mockIsAppStudio.mockReturnValue(false); diff --git a/packages/generator-adp/test/unit/questions/configuration.test.ts b/packages/generator-adp/test/unit/questions/configuration.test.ts index f94df41e977..f1aa4230200 100644 --- a/packages/generator-adp/test/unit/questions/configuration.test.ts +++ b/packages/generator-adp/test/unit/questions/configuration.test.ts @@ -148,7 +148,7 @@ describe('ConfigPrompter Integration Tests', () => { it('should return four prompts with correct names', () => { const prompts = configPrompter.getPrompts(); - expect(prompts).toHaveLength(9); + expect(prompts).toHaveLength(10); const names = prompts.map((p) => p.name); names.map((name) => { @@ -444,6 +444,31 @@ describe('ConfigPrompter Integration Tests', () => { }); }); + describe('Store Credentials Prompt', () => { + it('storeCredentials prompt additionalMessages should return warning when input is true', () => { + const prompts = configPrompter.getPrompts(); + const storeCredentialsPrompt = prompts.find((p) => p.name === configPromptNames.storeCredentials); + expect(storeCredentialsPrompt).toBeDefined(); + + const additionalMessages = storeCredentialsPrompt?.additionalMessages?.(true); + + expect(additionalMessages).toEqual({ + message: t('warnings.passwordStoreWarning'), + severity: Severity.warning + }); + }); + + it('storeCredentials prompt additionalMessages should return undefined when input is false', () => { + const prompts = configPrompter.getPrompts(); + const storeCredentialsPrompt = prompts.find((p) => p.name === configPromptNames.storeCredentials); + expect(storeCredentialsPrompt).toBeDefined(); + + const additionalMessages = storeCredentialsPrompt?.additionalMessages?.(false); + + expect(additionalMessages).toBeUndefined(); + }); + }); + describe('Application Prompt', () => { let getManifestSpy: jest.SpyInstance; const mockManifest = { 'sap.ui5': { flexEnabled: true } } as Manifest; diff --git a/packages/generator-adp/test/unit/questions/helper/conditions.test.ts b/packages/generator-adp/test/unit/questions/helper/conditions.test.ts index ff8506d46d2..d4436d8bd89 100644 --- a/packages/generator-adp/test/unit/questions/helper/conditions.test.ts +++ b/packages/generator-adp/test/unit/questions/helper/conditions.test.ts @@ -7,7 +7,8 @@ import { showCredentialQuestion, showExtensionProjectQuestion, showInternalQuestions, - showBusinessSolutionNameQuestion + showBusinessSolutionNameQuestion, + showStoreCredentialsQuestion } from '../../../../src/app/questions/helper/conditions'; jest.mock('@sap-ux/btp-utils', () => ({ @@ -202,3 +203,36 @@ describe('showBusinessSolutionNameQuestion', () => { expect(result).toBe(false); }); }); + +describe('showStoreCredentialsQuestion', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return true when not in AppStudio, system is provided, auth is required, login is successful, and password exists', () => { + mockIsAppStudio.mockReturnValue(false); + const answers = { + system: 'TestSystem', + username: 'user', + password: 'pass' + } as ConfigAnswers; + const result = showStoreCredentialsQuestion(answers, true, true); + expect(result).toBe(true); + }); + + it('should return false when any condition is not met', () => { + mockIsAppStudio.mockReturnValue(true); + const answers = { + system: 'TestSystem', + username: 'user', + password: 'pass' + } as ConfigAnswers; + expect(showStoreCredentialsQuestion(answers, true, true)).toBe(false); + + mockIsAppStudio.mockReturnValue(false); + expect(showStoreCredentialsQuestion(answers, false, true)).toBe(false); + expect(showStoreCredentialsQuestion(answers, true, false)).toBe(false); + expect(showStoreCredentialsQuestion({ ...answers, password: '' } as ConfigAnswers, true, true)).toBe(false); + expect(showStoreCredentialsQuestion({ ...answers, system: '' } as ConfigAnswers, true, true)).toBe(false); + }); +});