diff --git a/.changeset/breezy-coins-camp.md b/.changeset/breezy-coins-camp.md new file mode 100644 index 00000000000..717ef96b31b --- /dev/null +++ b/.changeset/breezy-coins-camp.md @@ -0,0 +1,15 @@ +--- +'@sap-ux/axios-extension': minor +'@sap-ux/store': minor +'@sap-ux/abap-deploy-config-sub-generator': patch +'@sap-ux/abap-deploy-config-inquirer': patch +'@sap-ux/backend-proxy-middleware': patch +'@sap-ux/fiori-app-sub-generator': patch +'@sap-ux/fiori-generator-shared': patch +'@sap-ux/odata-service-inquirer': patch +'@sap-ux/feature-toggle': patch +'@sap-ux/system-access': patch +'@sap-ux/telemetry': patch +--- + +Connections to Abap cloud will always use re-entrance tickets instead of UAA/OAuth2 diff --git a/packages/abap-deploy-config-inquirer/src/prompts/conditions.ts b/packages/abap-deploy-config-inquirer/src/prompts/conditions.ts index da90d61f2c3..120cb924923 100644 --- a/packages/abap-deploy-config-inquirer/src/prompts/conditions.ts +++ b/packages/abap-deploy-config-inquirer/src/prompts/conditions.ts @@ -60,7 +60,7 @@ export function showScpQuestion(previousAnswers: AbapDeployConfigAnswersInternal */ function showClientCondition(scp?: boolean): boolean { return Boolean( - !isAppStudio() && !PromptState.abapDeployConfig?.isS4HC && !scp && !PromptState.abapDeployConfig?.scp + !isAppStudio() && !PromptState.abapDeployConfig?.isAbapCloud && !scp && !PromptState.abapDeployConfig?.scp ); } @@ -142,7 +142,7 @@ export function showUi5AppDeployConfigQuestion(ui5AbapPromptOptions?: UI5AbapRep !ui5AbapPromptOptions?.hide && ui5AbapPromptOptions?.hideIfOnPremise && !PromptState.abapDeployConfig?.scp && - !PromptState.abapDeployConfig?.isS4HC + !PromptState.abapDeployConfig?.isAbapCloud ) { return false; } @@ -238,7 +238,7 @@ function defaultOrShowTransportQuestion(): boolean { export function showTransportInputChoice(options?: TransportInputChoicePromptOptions): boolean { if ( options?.hideIfOnPremise === true && - !PromptState.abapDeployConfig?.isS4HC && + !PromptState.abapDeployConfig?.isAbapCloud && !PromptState.abapDeployConfig?.scp ) { return false; @@ -276,7 +276,7 @@ export function defaultOrShowTransportListQuestion( return ( transportInputChoice === TransportChoices.ListExistingChoice && !isTransportListEmpty(PromptState.transportAnswers.transportList) && - !(transportInputChoiceOptions?.hideIfOnPremise === true && PromptState?.abapDeployConfig?.isS4HC === false) + !(transportInputChoiceOptions?.hideIfOnPremise === true && PromptState?.abapDeployConfig?.isAbapCloud === false) ); } @@ -311,7 +311,8 @@ export function defaultOrShowManualTransportQuestion( return ( defaultOrShowTransportQuestion() && (transportInputChoice === TransportChoices.EnterManualChoice || - (transportInputChoiceOptions?.hideIfOnPremise === true && PromptState?.abapDeployConfig?.isS4HC === false)) + (transportInputChoiceOptions?.hideIfOnPremise === true && + PromptState?.abapDeployConfig?.isAbapCloud === false)) ); } diff --git a/packages/abap-deploy-config-inquirer/src/prompts/helpers.ts b/packages/abap-deploy-config-inquirer/src/prompts/helpers.ts index da1d3d04279..ecdfd06e0d8 100644 --- a/packages/abap-deploy-config-inquirer/src/prompts/helpers.ts +++ b/packages/abap-deploy-config-inquirer/src/prompts/helpers.ts @@ -17,7 +17,7 @@ import { } from '../types'; import { AuthenticationType, type BackendSystem } from '@sap-ux/store'; import type { ChoiceOptions, ListChoiceOptions } from 'inquirer'; -import { getSystemDisplayName } from '@sap-ux/fiori-generator-shared'; +import { getBackendSystemDisplayName, getSystemDisplayName } from '@sap-ux/fiori-generator-shared'; import type { AbapServiceProvider } from '@sap-ux/axios-extension'; /** @@ -40,32 +40,6 @@ function getDestinationChoices(destinations: Destinations = {}): AbapSystemChoic return systemChoices; } -/** - * Returns the display name for the backend system. - * - * @param options options for display name - * @param options.backendSystem backend system - * @param options.includeUserName include user name in the display name - * @returns backend display name - */ -function getBackendDisplayName({ - backendSystem, - includeUserName = true -}: { - backendSystem: BackendSystem; - includeUserName?: boolean; -}): string { - const userDisplayName = includeUserName && backendSystem.userDisplayName ? `${backendSystem.userDisplayName}` : ''; - const systemDisplayName = getSystemDisplayName( - backendSystem.name, - userDisplayName, - !!backendSystem.serviceKeys, - backendSystem.authenticationType === AuthenticationType.ReentranceTicket - ); - - return systemDisplayName; -} - /** * Returns a list of backend system choices. * @@ -102,12 +76,12 @@ async function getBackendTargetChoices( } return { name: isDefault - ? `${getBackendDisplayName({ backendSystem: system })} (Source system)` - : getBackendDisplayName({ backendSystem: system }) ?? '', + ? `${getBackendSystemDisplayName(system)} (Source system)` + : getBackendSystemDisplayName(system) ?? '', value: system.url, isDefault, - scp: !!system.serviceKeys, - isS4HC: system.authenticationType === AuthenticationType.ReentranceTicket, + scp: !!system.serviceKeys, // legacy service key store entries + isAbapCloud: system.authenticationType === AuthenticationType.ReentranceTicket, client: system.client }; }); @@ -123,13 +97,14 @@ async function getBackendTargetChoices( name: `${getSystemDisplayName( systemName, user, - target.scp, - target.authenticationType === AuthenticationType.ReentranceTicket + target.scp || target.authenticationType === AuthenticationType.ReentranceTicket + ? 'ABAPCloud' + : undefined // scp is retained for legacy apps yamls that contain this value )} (Source system)`, value: target.url, isDefault: true, scp: target.scp, - isS4HC: target.authenticationType === AuthenticationType.ReentranceTicket, + isAbapCloud: target.authenticationType === AuthenticationType.ReentranceTicket, client: target.client }); } diff --git a/packages/abap-deploy-config-inquirer/src/prompts/validators.ts b/packages/abap-deploy-config-inquirer/src/prompts/validators.ts index 620feb434ec..b82fb3467d0 100644 --- a/packages/abap-deploy-config-inquirer/src/prompts/validators.ts +++ b/packages/abap-deploy-config-inquirer/src/prompts/validators.ts @@ -50,7 +50,7 @@ const allowedPackagePrefixes = ['$', 'Z', 'Y', 'SAP']; async function validateSystemType(options?: TargetSystemPromptOptions): Promise { if (options?.additionalValidation?.shouldRestrictDifferentSystemType) { const isDefaultProviderAbapCloud = AbapServiceProviderManager.getIsDefaultProviderAbapCloud(); - const isSelectedS4HC = PromptState?.abapDeployConfig?.isS4HC; + const isSelectedS4HC = PromptState?.abapDeployConfig?.isAbapCloud; if (isDefaultProviderAbapCloud === true && isSelectedS4HC === false) { return t('errors.validators.invalidCloudSystem'); } else if (isDefaultProviderAbapCloud === false && isSelectedS4HC) { @@ -90,26 +90,26 @@ export async function validateDestinationQuestion( * @param props - properties to update * @param props.url - url * @param props.client - client - * @param props.isS4HC - is S/4HANA Cloud + * @param props.isAbapCloud - Cloud based Abap (either Steampunk or Embedded Steampunk) * @param props.scp - is SCP * @param props.target - target system */ function updatePromptState({ url, client, - isS4HC, + isAbapCloud, scp, target }: { url: string; client?: string; - isS4HC?: boolean; + isAbapCloud?: boolean; scp?: boolean; target?: string; }): void { PromptState.abapDeployConfig.url = url; PromptState.abapDeployConfig.client = client; - PromptState.abapDeployConfig.isS4HC = isS4HC; + PromptState.abapDeployConfig.isAbapCloud = isAbapCloud; PromptState.abapDeployConfig.scp = scp; PromptState.abapDeployConfig.targetSystem = target; } @@ -134,13 +134,12 @@ export async function updateDestinationPromptState( updatePromptState({ url: dest?.Host, client: dest['sap-client'], - isS4HC: isS4HC(dest), + isAbapCloud: isS4HC(dest), scp: isAbapEnvironmentOnBtp(dest) }); if (options?.additionalValidation?.shouldRestrictDifferentSystemType) { - const isS4HCloud = await isAbapCloud(backendTarget); - PromptState.abapDeployConfig.isS4HC = isS4HCloud ?? false; + PromptState.abapDeployConfig.isAbapCloud = (await isAbapCloud(backendTarget)) ?? false; } } } @@ -171,7 +170,7 @@ export async function validateTargetSystem( url: choice.value, client: choice.client ?? '', scp: choice.scp, - isS4HC: choice.isS4HC, + isAbapCloud: choice.isAbapCloud, target: target }); } @@ -202,7 +201,7 @@ export function validateUrl(input: string): boolean | string { url: input.trim(), client: backendSystem?.client, scp: !!backendSystem?.serviceKeys, - isS4HC: backendSystem?.authenticationType === AuthenticationType.ReentranceTicket + isAbapCloud: backendSystem?.authenticationType === AuthenticationType.ReentranceTicket }); } else { return t('errors.invalidUrl', { url: input?.trim() }); @@ -305,8 +304,7 @@ export async function validateCredentials( }); if (isAppStudio() && shouldCheckSystemType) { - const isS4HCloud = await isAbapCloud(backendTarget); - PromptState.abapDeployConfig.isS4HC = isS4HCloud ?? false; + PromptState.abapDeployConfig.isAbapCloud = (await isAbapCloud(backendTarget)) ?? false; } PromptState.transportAnswers.transportConfigNeedsCreds = transportConfigNeedsCreds ?? false; @@ -646,8 +644,8 @@ export function validateConfirmQuestion(overwrite: boolean): boolean { * @returns {Promise} - Resolves to `true` if the package is cloud-ready, `false` otherwise. */ async function validatePackageType(input: string, backendTarget?: BackendTarget): Promise { - const isS4HC = PromptState?.abapDeployConfig?.isS4HC; - if (!isS4HC) { + const isAbapCloud = PromptState?.abapDeployConfig?.isAbapCloud; + if (!isAbapCloud) { LoggerHelper.logger.debug(`System is OnPremise, skipping package "${input}" type validation`); return true; } @@ -819,7 +817,7 @@ function shouldValidatePackageForStartingPrefix( !ui5AbapPromptOptions?.hide && !( ui5AbapPromptOptions?.hideIfOnPremise === true && - PromptState.abapDeployConfig?.isS4HC === false && + PromptState.abapDeployConfig?.isAbapCloud === false && PromptState.abapDeployConfig?.scp === false ) ); diff --git a/packages/abap-deploy-config-inquirer/src/service-provider-utils/abap-service-provider.ts b/packages/abap-deploy-config-inquirer/src/service-provider-utils/abap-service-provider.ts index 7390f5f6bf5..cad0d807afe 100644 --- a/packages/abap-deploy-config-inquirer/src/service-provider-utils/abap-service-provider.ts +++ b/packages/abap-deploy-config-inquirer/src/service-provider-utils/abap-service-provider.ts @@ -169,7 +169,7 @@ export class AbapServiceProviderManager { } as UrlAbapTarget; if ( - PromptState.abapDeployConfig.isS4HC ?? + PromptState.abapDeployConfig.isAbapCloud ?? backendTarget?.abapTarget.authenticationType === AuthenticationType.ReentranceTicket ) { abapTarget.authenticationType = AuthenticationType.ReentranceTicket; diff --git a/packages/abap-deploy-config-inquirer/src/types.ts b/packages/abap-deploy-config-inquirer/src/types.ts index b4cc108b656..82e37033f93 100644 --- a/packages/abap-deploy-config-inquirer/src/types.ts +++ b/packages/abap-deploy-config-inquirer/src/types.ts @@ -46,7 +46,7 @@ export interface AbapSystemChoice { url?: string; client?: string; isDefault?: boolean; - isS4HC?: boolean; + isAbapCloud?: boolean; } /** @@ -255,7 +255,7 @@ export interface AbapDeployConfigAnswers { export interface AbapDeployConfigAnswersInternal extends AbapDeployConfigAnswers { clientChoice?: string; username?: string; - isS4HC?: boolean; + isAbapCloud?: boolean; packageInputChoice?: PackageInputChoices; packageManual?: string; packageAutocomplete?: string; diff --git a/packages/abap-deploy-config-inquirer/test/fixtures/targets.ts b/packages/abap-deploy-config-inquirer/test/fixtures/targets.ts index 660a8e8947c..53dedbc1393 100644 --- a/packages/abap-deploy-config-inquirer/test/fixtures/targets.ts +++ b/packages/abap-deploy-config-inquirer/test/fixtures/targets.ts @@ -1,4 +1,4 @@ -import { AuthenticationType } from '@sap-ux/store'; +import { AuthenticationType, BackendSystem } from '@sap-ux/store'; export const mockTargetSystems = [ { @@ -12,7 +12,8 @@ export const mockTargetSystems = [ url: 'https://mock.url.target2.com', client: '102', userDisplayName: 'mockUser2', - authenticationType: AuthenticationType.ReentranceTicket + authenticationType: AuthenticationType.ReentranceTicket, + systemType: 'ABAPCloud' }, { name: 'target3', diff --git a/packages/abap-deploy-config-inquirer/test/prompts/conditions.test.ts b/packages/abap-deploy-config-inquirer/test/prompts/conditions.test.ts index ce5f7bdfdfb..d64c1ab38d5 100644 --- a/packages/abap-deploy-config-inquirer/test/prompts/conditions.test.ts +++ b/packages/abap-deploy-config-inquirer/test/prompts/conditions.test.ts @@ -69,20 +69,20 @@ describe('Test abap deploy config inquirer conditions', () => { test('should show client choice question', () => { mockIsAppStudio.mockReturnValueOnce(false); PromptState.isYUI = false; - PromptState.abapDeployConfig.isS4HC = false; + PromptState.abapDeployConfig.isAbapCloud = false; expect( showClientChoiceQuestion({ scp: false, targetSystem: TargetSystemType.Url, url: '', package: '' }, '100') ).toBe(true); PromptState.resetAbapDeployConfig(); // Should not show client choice question if SCP is enabled - PromptState.abapDeployConfig.isS4HC = false; + PromptState.abapDeployConfig.isAbapCloud = false; PromptState.abapDeployConfig.scp = true; expect( showClientChoiceQuestion({ scp: false, targetSystem: TargetSystemType.Url, url: '', package: '' }, '100') ).toBe(false); PromptState.resetAbapDeployConfig(); // Should not show client choice question if target system is not a URL - PromptState.abapDeployConfig.isS4HC = false; + PromptState.abapDeployConfig.isAbapCloud = false; PromptState.abapDeployConfig.scp = true; expect(showClientChoiceQuestion({ scp: true, url: '', package: '' }, '100')).toBe(false); PromptState.resetAbapDeployConfig(); @@ -91,7 +91,7 @@ describe('Test abap deploy config inquirer conditions', () => { test('should not show client choice question', () => { mockIsAppStudio.mockReturnValueOnce(false); PromptState.isYUI = false; - PromptState.abapDeployConfig.isS4HC = true; + PromptState.abapDeployConfig.isAbapCloud = true; expect( showClientChoiceQuestion({ scp: true, targetSystem: TargetSystemType.Url, url: '', package: '' }, undefined) ).toBe(false); @@ -114,14 +114,14 @@ describe('Test abap deploy config inquirer conditions', () => { PromptState.isYUI = isYui; mockIsAppStudio.mockReturnValueOnce(false); // Validate client question if SCP is enabled - PromptState.abapDeployConfig.isS4HC = false; + PromptState.abapDeployConfig.isAbapCloud = false; expect(showClientQuestion({ scp: true, targetSystem: TargetSystemType.Url, url: '', package: '' })).toBe( scpEnabled ); PromptState.resetAbapDeployConfig(); // Validate client question if SCP is disabled PromptState.abapDeployConfig.client = '100'; - PromptState.abapDeployConfig.isS4HC = false; + PromptState.abapDeployConfig.isAbapCloud = false; expect( showClientQuestion({ scp: false, @@ -190,7 +190,7 @@ describe('Test abap deploy config inquirer conditions', () => { }; PromptState.abapDeployConfig.scp = false; expect(showUi5AppDeployConfigQuestion(promptOptions)).toBe(false); - PromptState.abapDeployConfig.isS4HC = false; + PromptState.abapDeployConfig.isAbapCloud = false; expect(showUi5AppDeployConfigQuestion(promptOptions)).toBe(false); }); @@ -237,7 +237,7 @@ describe('Test abap deploy config inquirer conditions', () => { test('should not show transport input choice question for onPremise systems', () => { PromptState.transportAnswers.transportRequired = false; - PromptState.abapDeployConfig.isS4HC = false; + PromptState.abapDeployConfig.isAbapCloud = false; PromptState.abapDeployConfig.scp = false; expect(showTransportInputChoice({ hideIfOnPremise: true })).toBe(false); }); @@ -267,7 +267,7 @@ describe('Test abap deploy config inquirer conditions', () => { }); test('should not show transport list question', () => { - PromptState.abapDeployConfig.isS4HC = false; + PromptState.abapDeployConfig.isAbapCloud = false; PromptState.transportAnswers.transportList = [ { transportReqNumber: 'K123456', transportReqDescription: 'Mock transport' } ]; @@ -303,7 +303,7 @@ describe('Test abap deploy config inquirer conditions', () => { }); test('should show manual transport question when transportInput choice is not provided and transportInputChoice is hidden', () => { - PromptState.abapDeployConfig.isS4HC = false; + PromptState.abapDeployConfig.isAbapCloud = false; expect(defaultOrShowManualTransportQuestion(undefined, { hideIfOnPremise: true })).toBe(true); }); diff --git a/packages/abap-deploy-config-inquirer/test/prompts/helpers.test.ts b/packages/abap-deploy-config-inquirer/test/prompts/helpers.test.ts index 702cd069514..1cd527b0d34 100644 --- a/packages/abap-deploy-config-inquirer/test/prompts/helpers.test.ts +++ b/packages/abap-deploy-config-inquirer/test/prompts/helpers.test.ts @@ -1,6 +1,7 @@ import { initI18n, t } from '../../src/i18n'; import { getAbapSystemChoices, getPackageChoices, updatePromptStateUrl } from '../../src/prompts/helpers'; import { PromptState } from '../../src/prompts/prompt-state'; +import type { BackendTarget } from '../../src/types'; import { queryPackages } from '../../src/utils'; import { mockDestinations } from '../fixtures/destinations'; import { mockTargetSystems } from '../fixtures/targets'; @@ -21,7 +22,7 @@ describe('helpers', () => { const mockServiceProvider = { user: () => 'mockUser2' } as any; - const backendTarget = { + const backendTarget: BackendTarget = { serviceProvider: mockServiceProvider, abapTarget: mockTargetSystems[1] }; @@ -34,32 +35,32 @@ describe('helpers', () => { }, Object { "client": "100", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target1 [mockUser]", "scp": false, "value": "https://mock.url.target1.com", }, Object { "client": "102", + "isAbapCloud": true, "isDefault": true, - "isS4HC": true, - "name": "target2 (S4HC) [mockUser2] (Source system)", + "name": "target2 (ABAP Cloud) [mockUser2] (Source system)", "scp": false, "value": "https://mock.url.target2.com", }, Object { "client": "103", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target3 [mockUser3]", "scp": false, "value": "https://mock.url.target3.com", }, Object { "client": "104", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target4 [mockUser4]", "scp": false, "value": "https://mock.url.target4.com", @@ -93,40 +94,40 @@ describe('helpers', () => { }, Object { "client": "100", + "isAbapCloud": false, "isDefault": true, - "isS4HC": false, "name": "New System [mockUser] (Source system)", "scp": undefined, "value": "https://mock.url.new.target.com", }, Object { "client": "100", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target1 [mockUser]", "scp": false, "value": "https://mock.url.target1.com", }, Object { "client": "102", + "isAbapCloud": true, "isDefault": false, - "isS4HC": true, - "name": "target2 (S4HC) [mockUser2]", + "name": "target2 (ABAP Cloud) [mockUser2]", "scp": false, "value": "https://mock.url.target2.com", }, Object { "client": "103", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target3 [mockUser3]", "scp": false, "value": "https://mock.url.target3.com", }, Object { "client": "104", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target4 [mockUser4]", "scp": false, "value": "https://mock.url.target4.com", @@ -145,32 +146,32 @@ describe('helpers', () => { }, Object { "client": "100", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target1 [mockUser]", "scp": false, "value": "https://mock.url.target1.com", }, Object { "client": "102", + "isAbapCloud": true, "isDefault": false, - "isS4HC": true, - "name": "target2 (S4HC) [mockUser2]", + "name": "target2 (ABAP Cloud) [mockUser2]", "scp": false, "value": "https://mock.url.target2.com", }, Object { "client": "103", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target3 [mockUser3]", "scp": false, "value": "https://mock.url.target3.com", }, Object { "client": "104", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target4 [mockUser4]", "scp": false, "value": "https://mock.url.target4.com", diff --git a/packages/abap-deploy-config-inquirer/test/prompts/questions/abap-target.test.ts b/packages/abap-deploy-config-inquirer/test/prompts/questions/abap-target.test.ts index 4f7fbb6b260..61cb21f3938 100644 --- a/packages/abap-deploy-config-inquirer/test/prompts/questions/abap-target.test.ts +++ b/packages/abap-deploy-config-inquirer/test/prompts/questions/abap-target.test.ts @@ -229,32 +229,32 @@ describe('getAbapTargetPrompts', () => { }, Object { "client": "100", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target1 [mockUser]", "scp": false, "value": "https://mock.url.target1.com", }, Object { "client": "102", + "isAbapCloud": true, "isDefault": false, - "isS4HC": true, - "name": "target2 (S4HC) [mockUser2]", + "name": "target2 (ABAP Cloud) [mockUser2]", "scp": false, "value": "https://mock.url.target2.com", }, Object { "client": "103", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target3 [mockUser3]", "scp": false, "value": "https://mock.url.target3.com", }, Object { "client": "104", + "isAbapCloud": false, "isDefault": false, - "isS4HC": false, "name": "target4 [mockUser4]", "scp": false, "value": "https://mock.url.target4.com", diff --git a/packages/abap-deploy-config-inquirer/test/prompts/validators.test.ts b/packages/abap-deploy-config-inquirer/test/prompts/validators.test.ts index c5461e9c4fb..b993526c0cc 100644 --- a/packages/abap-deploy-config-inquirer/test/prompts/validators.test.ts +++ b/packages/abap-deploy-config-inquirer/test/prompts/validators.test.ts @@ -24,6 +24,7 @@ import { } from '../../src/prompts/validators'; import * as serviceProviderUtils from '../../src/service-provider-utils'; import { AbapServiceProviderManager } from '../../src/service-provider-utils/abap-service-provider'; +import type { AbapSystemChoice } from '../../src/types'; import { ClientChoiceValue, PackageInputChoices, TargetSystemType, TransportChoices } from '../../src/types'; import * as utils from '../../src/utils'; import * as validatorUtils from '../../src/validator-utils'; @@ -54,6 +55,12 @@ describe('Test validators', () => { beforeAll(async () => { await initI18n(); }); + + beforeEach(() => { + // reset propmt state for test isolation + PromptState.abapDeployConfig = {}; + }); + describe('validateDestinationQuestion', () => { it('should return true for valid destination', async () => { const result = await validateDestinationQuestion('Dest2', mockDestinations); @@ -91,19 +98,19 @@ describe('Test validators', () => { }); describe('validateTargetSystem', () => { - const abapSystemChoices = [ + const abapSystemChoices: AbapSystemChoice[] = [ { name: 'Target1', value: 'https://mock.url.target1.com', client: '001', - isS4HC: false, + isAbapCloud: false, scp: false }, { name: 'Target2', value: 'https://mock.url.target2.com', client: '002', - isS4HC: true, + isAbapCloud: true, scp: false } ]; @@ -118,8 +125,7 @@ describe('Test validators', () => { expect(PromptState.abapDeployConfig).toStrictEqual({ url: 'https://mock.url.target1.com', client: '001', - destination: undefined, - isS4HC: false, + isAbapCloud: false, scp: false, targetSystem: 'https://mock.url.target1.com' }); @@ -163,8 +169,7 @@ describe('Test validators', () => { expect(PromptState.abapDeployConfig).toStrictEqual({ url: 'https://mock.url.target1.com', client: '001', - destination: undefined, - isS4HC: true, + isAbapCloud: true, scp: true, targetSystem: undefined }); @@ -176,11 +181,10 @@ describe('Test validators', () => { expect(result).toBe(true); expect(PromptState.abapDeployConfig).toStrictEqual({ url: 'https://mock.notfound.url.target1.com', - client: undefined, - destination: undefined, - isS4HC: false, + isAbapCloud: false, scp: false, - targetSystem: undefined + targetSystem: undefined, + client: undefined }); }); @@ -307,7 +311,7 @@ describe('Test validators', () => { expect(result).toBe(true); expect(mockIsAppStudio).toHaveBeenCalled(); expect(serviceProviderUtils.isAbapCloud).toHaveBeenCalled(); - expect(PromptState.abapDeployConfig.isS4HC).toBe(true); + expect(PromptState.abapDeployConfig.isAbapCloud).toBe(true); }); it('should not check system type when shouldCheckSystemType is false', async () => { @@ -449,7 +453,7 @@ describe('Test validators', () => { }); it('should return true for onPremise system', async () => { - PromptState.abapDeployConfig.isS4HC = false; + PromptState.abapDeployConfig.isAbapCloud = false; const getSystemInfoSpy = jest.spyOn(serviceProviderUtils, 'getSystemInfo'); const result = await validatePackage('ZPACKAGE', previousAnswers, { additionalValidation: { shouldValidatePackageType: true } @@ -480,7 +484,7 @@ describe('Test validators', () => { }); it('should return error for invalid starting prefix', async () => { - PromptState.abapDeployConfig.isS4HC = false; + PromptState.abapDeployConfig.isAbapCloud = false; PromptState.abapDeployConfig.scp = true; const result = await validatePackage( 'namespace', @@ -499,7 +503,7 @@ describe('Test validators', () => { }); it('should return error for invalid ui5Repo starting prefix', async () => { - PromptState.abapDeployConfig.isS4HC = true; + PromptState.abapDeployConfig.isAbapCloud = true; PromptState.abapDeployConfig.scp = false; const result = await validatePackage( 'ZPACKAGE', @@ -518,7 +522,7 @@ describe('Test validators', () => { }); it('should return error for invalid ui5Repo starting prefix package starting with namespace', async () => { - PromptState.abapDeployConfig.isS4HC = true; + PromptState.abapDeployConfig.isAbapCloud = true; PromptState.abapDeployConfig.scp = false; const result = await validatePackage( '/NAMESPACE/ZPACKAGE', @@ -544,7 +548,7 @@ describe('Test validators', () => { activeLanguages: [] } }); - PromptState.abapDeployConfig.isS4HC = true; + PromptState.abapDeployConfig.isAbapCloud = true; const result = await validatePackage('ZPACKAGE', previousAnswers, { additionalValidation: { shouldValidatePackageType: true } }); diff --git a/packages/abap-deploy-config-inquirer/test/service-provider-utils/abap-service-provider.test.ts b/packages/abap-deploy-config-inquirer/test/service-provider-utils/abap-service-provider.test.ts index f49d3088adc..704959d60dd 100644 --- a/packages/abap-deploy-config-inquirer/test/service-provider-utils/abap-service-provider.test.ts +++ b/packages/abap-deploy-config-inquirer/test/service-provider-utils/abap-service-provider.test.ts @@ -44,7 +44,7 @@ describe('getOrCreateServiceProvider', () => { url: 'http://target.url', client: '100', scp: false, - isS4HC: true + isAbapCloud: true }; const credentials = { @@ -119,7 +119,7 @@ describe('getOrCreateServiceProvider', () => { url: 'http://target.url', client: '100', scp: false, - isS4HC: true + isAbapCloud: true }; const credentials = { diff --git a/packages/abap-deploy-config-sub-generator/src/app/index.ts b/packages/abap-deploy-config-sub-generator/src/app/index.ts index ca0c80df2f5..3534af42b5b 100644 --- a/packages/abap-deploy-config-sub-generator/src/app/index.ts +++ b/packages/abap-deploy-config-sub-generator/src/app/index.ts @@ -228,9 +228,9 @@ export default class extends DeploymentGenerator { client: this.answers.client, destination: this.answers.destination })); - this.answers.isS4HC = - this.options.isS4HC || - this.answers.isS4HC || + this.answers.isAbapCloud = + this.options.isAbapCloud || + this.answers.isAbapCloud || (await determineS4HCFromTarget({ url: this.answers.url, client: this.answers.client, @@ -290,7 +290,7 @@ export default class extends DeploymentGenerator { if (this.abort || this.answers.overwrite === false) { return; } - const namespace = await getVariantNamespace(this.destinationPath(), !!this.answers.isS4HC, this.fs); + const namespace = await getVariantNamespace(this.destinationPath(), !!this.answers.isAbapCloud, this.fs); await generateAbapDeployConfig( this.destinationPath(), { @@ -299,7 +299,7 @@ export default class extends DeploymentGenerator { client: this.answers.client, scp: this.answers.scp, destination: this.answers.destination, - authenticationType: this.answers.isS4HC ? AuthenticationType.ReentranceTicket : undefined // only reentrance ticket is relevant for writing to deploy config + authenticationType: this.answers.isAbapCloud ? AuthenticationType.ReentranceTicket : undefined }, app: { name: this.answers.ui5AbapRepo, diff --git a/packages/abap-deploy-config-sub-generator/src/utils/helpers.ts b/packages/abap-deploy-config-sub-generator/src/utils/helpers.ts index 863622fb45e..9e071dccf33 100644 --- a/packages/abap-deploy-config-sub-generator/src/utils/helpers.ts +++ b/packages/abap-deploy-config-sub-generator/src/utils/helpers.ts @@ -102,16 +102,16 @@ export async function determineScpFromTarget(target: AbapTarget): Promise { - let isS4HCloud = false; + let isAbapCloud = false; if (isAppStudio() && target.destination) { const destinations = await getDestinations(); if (destinations?.[target.destination]) { - isS4HCloud = isS4HC(destinations?.[target.destination]); + isAbapCloud = isS4HC(destinations?.[target.destination]); } } else if (target.url) { const backendSystems = await getBackendSystems(); const backendSystem = backendSystems?.find((backend: BackendSystem) => isSameSystem(backend, target)); - isS4HCloud = backendSystem?.authenticationType === AuthenticationType.ReentranceTicket; + isAbapCloud = backendSystem?.authenticationType === AuthenticationType.ReentranceTicket; } - return isS4HCloud; + return isAbapCloud; } diff --git a/packages/axios-extension/src/abap/abap-service-provider.ts b/packages/axios-extension/src/abap/abap-service-provider.ts index 01d022fead2..0fcd25a3d7c 100644 --- a/packages/axios-extension/src/abap/abap-service-provider.ts +++ b/packages/axios-extension/src/abap/abap-service-provider.ts @@ -1,19 +1,20 @@ +import { ODataVersion } from '../base/odata-service'; import { ServiceProvider } from '../base/service-provider'; +import { AdtCatalogService } from './adt-catalog/adt-catalog-service'; +import { AppIndexService } from './app-index-service'; import type { CatalogService } from './catalog'; import { V2CatalogService, V4CatalogService } from './catalog'; -import { Ui5AbapRepositoryService } from './ui5-abap-repository-service'; -import { AppIndexService } from './app-index-service'; -import { ODataVersion } from '../base/odata-service'; import { LayeredRepositoryService } from './lrep-service'; -import { AdtCatalogService } from './adt-catalog/adt-catalog-service'; -import type { AbapCDSView, AtoSettings, BusinessObject } from './types'; +import type { AbapCDSView, AtoSettings, BusinessObject, SystemInfo } from './types'; import { TenantType } from './types'; +import { Ui5AbapRepositoryService } from './ui5-abap-repository-service'; // Can't use an `import type` here. We need the classname at runtime to create object instances: // eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { AdtService, AtoService, GeneratorService, RapGeneratorService } from './adt-catalog/services'; import { ODataServiceGenerator } from './adt-catalog/generators/odata-service-generator'; -import { UiServiceGenerator } from './adt-catalog/generators/ui-service-generator'; import type { GeneratorEntry } from './adt-catalog/generators/types'; +import { UiServiceGenerator } from './adt-catalog/generators/ui-service-generator'; +import { type AdtService, AtoService, GeneratorService, RapGeneratorService } from './adt-catalog/services'; +import { SystemInfoService } from './adt-catalog/services/systeminfo-service'; /** * Extension of the service provider for ABAP services. @@ -26,14 +27,37 @@ export class AbapServiceProvider extends ServiceProvider { */ protected _publicUrl: string; + /** + * The connected system info + */ + protected _systemInfo: SystemInfo | undefined; + /** * Get the name of the currently logged in user. This is the basic implementation that could be overwritten by subclasses. * The function returns a promise because it may be required to fetch the information from the backend. * * @returns the username */ - public user(): Promise { - return Promise.resolve(this.defaults.auth?.username); + public async user(): Promise { + return (await Promise.resolve(this.defaults.auth?.username)) || (await this.getSystemInfo())?.userName; + } + + /** + * Get user information. + * + * @returns user name or undefined + */ + public async getSystemInfo(): Promise { + if (this._systemInfo) { + return this._systemInfo; + } + try { + const systemInfoService = this.createService('', SystemInfoService); + this._systemInfo = await systemInfoService.getSystemInfo(); + } catch (error) { + this.log.error(`An error occurred retrieving system info: ${error}`); + } + return this._systemInfo; } /** diff --git a/packages/axios-extension/src/abap/adt-catalog/services/systeminfo-service.ts b/packages/axios-extension/src/abap/adt-catalog/services/systeminfo-service.ts new file mode 100644 index 00000000000..72a48120603 --- /dev/null +++ b/packages/axios-extension/src/abap/adt-catalog/services/systeminfo-service.ts @@ -0,0 +1,30 @@ +import type { SystemInfo } from 'abap/types'; +import { AdtService } from './adt-service'; + +/** + * Retrieve system information using the ADT endpoint + */ +export class SystemInfoService extends AdtService { + /** + * Send ADT request to fetch ATO settings. + * + * @returns AtoSettings + */ + public async getSystemInfo(): Promise { + const acceptHeaders = { + headers: { + Accept: 'application/vnd.sap.adt.core.http.systeminformation.v1+json' + } + }; + const response = await this.get('/sap/bc/adt/core/http/systeminformation', acceptHeaders); + if (typeof response.data === 'string') { + try { + return JSON.parse(response.data); + } catch (parseError) { + this.log.error(`System info could not be parsed from response. Error: ${parseError.message}`); + } + } else { + return response.data; + } + } +} diff --git a/packages/axios-extension/src/abap/types/adt-types.ts b/packages/axios-extension/src/abap/types/adt-types.ts index 0162140767d..e4e9d916a40 100644 --- a/packages/axios-extension/src/abap/types/adt-types.ts +++ b/packages/axios-extension/src/abap/types/adt-types.ts @@ -162,3 +162,14 @@ export type ODataServiceTechnicalDetails = { }; export type ValidationResponse = { severity: string; short_text: string; long_text: string }; + +/** + * Type for '/sap/bc/adt/core/http/systeminformation' response type 'application/vnd.sap.adt.core.http.systeminformation.v1+json' + */ +export type SystemInfo = { + systemID: string; + userName: string; + userFullName: string; + client: string; + language: string; +}; diff --git a/packages/axios-extension/src/auth/index.ts b/packages/axios-extension/src/auth/index.ts index 2c41cbbbeaa..96f45159739 100644 --- a/packages/axios-extension/src/auth/index.ts +++ b/packages/axios-extension/src/auth/index.ts @@ -1,10 +1,10 @@ -import { ServiceInfo } from '@sap-ux/btp-utils'; import { AxiosHeaders } from 'axios'; import type { Axios, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import type { ServiceProvider } from '../base/service-provider'; -import type { AbapServiceProvider } from '../abap'; import { getReentranceTicket } from './reentrance-ticket'; import { RefreshTokenChanged, Uaa } from './uaa'; +import type { AbapServiceProvider } from 'abap/abap-service-provider'; +import type { ServiceInfo } from '@sap-ux/btp-utils'; export * from './connection'; export * from './error'; @@ -90,20 +90,16 @@ export function getReentranceTicketAuthInterceptor({ ejectCallback: () => void; }): (request: InternalAxiosRequestConfig) => Promise> { return async (request: InternalAxiosRequestConfig) => { - const { reentranceTicket, apiUrl } = await getReentranceTicket({ + const { reentranceTicket, backend } = await getReentranceTicket({ backendUrl: provider.defaults.baseURL, logger: provider.log }); - if (apiUrl && apiUrl != provider.defaults.baseURL) { - // Reentrance tickets work with API hostnames. If the original URL was not one, this will replace it - // with the API hostname returned - provider.log.warn( - `Replacing provider's default base URL (${provider.defaults.baseURL}) with API URL: ${apiUrl}` - ); - provider.defaults.baseURL = apiUrl; - } + // Update the base host (provided system url) to the API host for subsequent calls as only this should be used with re-entrance tickets to generate a secure session + provider.defaults.baseURL = (await backend?.apiHostname()) ?? provider.defaults.baseURL; request.headers = request.headers ?? new AxiosHeaders(); request.headers.MYSAPSSO2 = reentranceTicket; + // Request a secure session using the reentrance token + request.headers['x-sap-security-session'] = 'create'; // remove this interceptor since it is not needed anymore ejectCallback(); return request; diff --git a/packages/axios-extension/src/auth/reentrance-ticket/abap-system.ts b/packages/axios-extension/src/auth/reentrance-ticket/abap-system.ts deleted file mode 100644 index 945a58d53ba..00000000000 --- a/packages/axios-extension/src/auth/reentrance-ticket/abap-system.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * - */ -export class ABAPSystem { - private apiURL: URL; - private uiURL: URL; - private systemURL: URL; - - /** - * - * @param backendUrl backend Url - */ - constructor(backendUrl: string) { - this.systemURL = new URL(backendUrl); - } - /** - * Removes any `-api` suffix in the first label of the hostname. - * - * @returns UI hostname - */ - uiHostname(): string { - if (!this.uiURL) { - this.uiURL = new URL(this.systemURL.href); - - const [first, ...rest] = this.uiURL.hostname.split('.'); - this.uiURL.hostname = [first.replace(/-api$/, ''), ...rest].join('.'); - } - return this.uiURL.origin; - } - - /** - * Adds a `-api` suffix to the first label of the hostname. - * - * @returns API hostname - */ - apiHostname(): string { - if (!this.apiURL) { - this.apiURL = new URL(this.systemURL.href); - - const [first, ...rest] = this.apiURL.hostname.split('.'); - if (!first.endsWith('-api')) { - this.apiURL.hostname = [first + '-api', ...rest].join('.'); - } - } - return this.apiURL.origin; - } - - /** - * - * @returns logoff URL - */ - logoffUrl(): string { - return this.uiHostname() + '/sap/public/bc/icf/logoff'; - } -} diff --git a/packages/axios-extension/src/auth/reentrance-ticket/abap-virtual-host-provider.ts b/packages/axios-extension/src/auth/reentrance-ticket/abap-virtual-host-provider.ts new file mode 100644 index 00000000000..e0acb14b3bb --- /dev/null +++ b/packages/axios-extension/src/auth/reentrance-ticket/abap-virtual-host-provider.ts @@ -0,0 +1,88 @@ +import { ToolsLogger, type Logger } from '@sap-ux/logger'; +import axios from 'axios'; + +type RelatedUrls = { + relatedUrls: { + API: string; + UI: string; + }; +}; +/** + * Makes requests to determine the virtual host names for UI and API access. + */ +export class ABAPVirtualHostProvider { + private apiURL: URL; + private uiURL: URL; + private readonly systemURL: URL; + private relatedUrls: RelatedUrls; + private readonly logger: Logger = new ToolsLogger(); + + /** + * + * @param backendUrl backend Url + * @param logger + */ + constructor(backendUrl: string, logger?: Logger) { + this.systemURL = new URL(backendUrl); + if (logger) { + this.logger = logger; + } + } + + /** + * Retrieves the virtual host names for UI and API access from the ABAP system public endpoint at the backend host. + * + * @returns An object containing the related URLs for API and UI access. + */ + private async getVirtualHosts(): Promise { + if (!this.relatedUrls) { + this.logger.debug(`Requesting virtual hosts from: ${this.systemURL}`); + const url = new URL('/sap/public/bc/icf/virtualhost', this.systemURL.origin); + const response = await axios.get(url.href, { + headers: { + Accept: 'application/json' + } + }); + + if (response.status !== 200) { + this.logger.debug(`Failed to fetch virtual hosts: from: ${url}. Error: ${response.statusText}`); + throw new Error(`Failed to fetch virtual hosts: ${response.statusText}`); + } + this.relatedUrls = response.data; + } + return this.relatedUrls; + } + + /** + * Get the UI hostname, if not cached yet it will be fetched. + * + * @returns UI hostname + */ + async uiHostname(): Promise { + if (!this.uiURL) { + this.uiURL = new URL((await this.getVirtualHosts()).relatedUrls.UI); + } + return this.uiURL.origin; + } + + /** + * Get the API hostname, if not cached yet it will be fetched. + * + * @returns API hostname + */ + async apiHostname(): Promise { + if (!this.apiURL) { + this.apiURL = new URL((await this.getVirtualHosts()).relatedUrls.API); + } + return this.apiURL.origin; + } + + /** + * Get the logoff URL. + * + * @returns logoff URL + */ + async logoffUrl(): Promise { + return (await this.uiHostname()) + '/sap/public/bc/icf/logoff'; + } +} diff --git a/packages/axios-extension/src/auth/reentrance-ticket/index.ts b/packages/axios-extension/src/auth/reentrance-ticket/index.ts index d850548ea15..2defe66f3c2 100644 --- a/packages/axios-extension/src/auth/reentrance-ticket/index.ts +++ b/packages/axios-extension/src/auth/reentrance-ticket/index.ts @@ -2,13 +2,9 @@ import type { Logger } from '@sap-ux/logger'; import type { AddressInfo } from 'node:net'; import open = require('open'); import { defaultTimeout } from '../connection'; -import { ABAPSystem } from './abap-system'; +import { ABAPVirtualHostProvider } from './abap-virtual-host-provider'; import { setupRedirectHandling } from './redirect'; -/** - * DO NOT USE THIS SERVICE ENDPOINT DIRECTLY. - * It might be removed in the future without notice. - */ const ADT_REENTRANCE_ENDPOINT = '/sap/bc/sec/reentrance'; /** @@ -27,19 +23,21 @@ export async function getReentranceTicket({ backendUrl: string; logger: Logger; timeout?: number; -}): Promise<{ reentranceTicket: string; apiUrl?: string }> { +}): Promise<{ reentranceTicket: string; backend?: ABAPVirtualHostProvider }> { + const backend = new ABAPVirtualHostProvider(backendUrl, logger); + const uiHostname = await backend.uiHostname(); return new Promise((resolve, reject) => { - const backend = new ABAPSystem(backendUrl); // Start local server to listen to redirect call, with timeout const { server, redirectUrl } = setupRedirectHandling({ resolve, reject, timeout, backend, logger }); server.listen(); - const redirectPort = (server.address() as AddressInfo).port; // Open browser to handle SAML flow and return the reentrance ticket const scenario = process.env.FIORI_TOOLS_SCENARIO ?? 'FTO1'; const endpoint = process.env.FIORI_TOOLS_REENTRANCE_ENDPOINT ?? ADT_REENTRANCE_ENDPOINT; - const url = `${backend.uiHostname()}${endpoint}?scenario=${scenario}&redirect-url=${redirectUrl(redirectPort)}`; - open(url)?.catch((error) => logger.error(error)); + const url = `${uiHostname}${endpoint}?scenario=${scenario}&redirect-url=${redirectUrl(redirectPort)}`; + + const result = open(url)?.catch((error) => logger.error(error)); + return result; }); } diff --git a/packages/axios-extension/src/auth/reentrance-ticket/redirect.ts b/packages/axios-extension/src/auth/reentrance-ticket/redirect.ts index 783c13a8cd9..16bf70d1a0e 100644 --- a/packages/axios-extension/src/auth/reentrance-ticket/redirect.ts +++ b/packages/axios-extension/src/auth/reentrance-ticket/redirect.ts @@ -3,7 +3,7 @@ import http from 'http'; import { ConnectionError, TimeoutError } from '../error'; import { prettyPrintTimeInMs } from '../../abap/message'; import { redirectErrorHtml, redirectSuccessHtml } from '../static'; -import type { ABAPSystem } from './abap-system'; +import type { ABAPVirtualHostProvider } from './abap-virtual-host-provider'; interface Redirect { server: http.Server; @@ -20,7 +20,7 @@ export interface SetupRedirectOptions { resolve; reject; timeout: number; - backend: ABAPSystem; + backend: ABAPVirtualHostProvider; logger: Logger; } @@ -46,7 +46,7 @@ export function setupRedirectHandling({ resolve, reject, timeout, backend, logge }; const timer = setTimeout(handleTimeout, timeout); - server = http.createServer((req, res) => { + server = http.createServer((req, res): void => { const reqUrl = new URL(req.url, `http://${req.headers.host}`); if (reqUrl.pathname === REDIRECT_PATH) { if (timer) { @@ -55,10 +55,16 @@ export function setupRedirectHandling({ resolve, reject, timeout, backend, logge const reentranceTicket = reqUrl.searchParams.get('reentrance-ticket')?.toString(); if (reentranceTicket) { logger.debug('Got reentrance ticket: ' + reentranceTicket); - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(Buffer.from(redirectSuccessHtml(backend.logoffUrl()))); + backend + .logoffUrl() + .then((url) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(Buffer.from(redirectSuccessHtml(url))); + }) + .catch(() => {}); server.close(); - resolve({ reentranceTicket, apiUrl: backend.apiHostname() }); + // return the backend for convienience + resolve({ reentranceTicket, backend }); } else { logger.error('Error getting reentrance ticket'); logger.debug(req); diff --git a/packages/axios-extension/src/base/service-provider.ts b/packages/axios-extension/src/base/service-provider.ts index 08aaf5634ed..832a94ff0b1 100644 --- a/packages/axios-extension/src/base/service-provider.ts +++ b/packages/axios-extension/src/base/service-provider.ts @@ -17,6 +17,11 @@ export interface ProviderConfiguration { * https://datatracker.ietf.org/doc/html/rfc6265#section-4.2 */ cookies: string; + + /** + * Allow a logger to be set by calls to provider creation + */ + logger?: Logger; } export interface ServiceProviderExtension { @@ -39,8 +44,20 @@ export class ServiceProvider extends Axios implements ServiceProviderExtension { protected readonly services: { [path: string]: Service } = {}; + /** + * + * @param providerConfig + */ + constructor(providerConfig?: AxiosRequestConfig & Partial) { + super(providerConfig); + if (providerConfig?.logger) { + this._log = providerConfig.logger; + } + } + /** * Set the logger for the service provider. Loggers may need to be restored after serialization/deserialization since they contain circular references. + * Note calling this will overwrite loggers sets via ProviderConfiguration. * * @param logger - Logger instance to be set */ @@ -78,10 +95,11 @@ export class ServiceProvider extends Axios implements ServiceProviderExtension { */ protected generateServiceConfig(path: string): AxiosRequestConfig { const config = Object.assign({}, this.defaults); + const headers = Object.assign(this.defaults.headers?.common ?? {}, { Cookie: this.cookies.toString() }); return { ...config, baseURL: this.defaults.baseURL + path, - headers: this.defaults.headers?.common ?? {} + headers }; } diff --git a/packages/axios-extension/src/factory.ts b/packages/axios-extension/src/factory.ts index 31418762087..c836d5f3322 100644 --- a/packages/axios-extension/src/factory.ts +++ b/packages/axios-extension/src/factory.ts @@ -1,30 +1,30 @@ -import type { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'; -import cloneDeep from 'lodash/cloneDeep'; import { - getDestinationUrlForAppStudio, + type Destination, + BAS_DEST_INSTANCE_CRED_HEADER, getCredentialsForDestinationService, + getDestinationUrlForAppStudio, isAbapSystem, - BAS_DEST_INSTANCE_CRED_HEADER, - isAppStudio, - type Destination + isAppStudio } from '@sap-ux/btp-utils'; +import type { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'; +import { HttpProxyAgent } from 'http-proxy-agent'; import { type AgentOptions, Agent as HttpsAgent } from 'https'; -import type { ServiceInfo, RefreshTokenChanged } from './auth'; +import { type HttpsProxyAgentOptions, HttpsProxyAgent } from 'https-proxy-agent'; +import cloneDeep from 'lodash/cloneDeep'; +import { getProxyForUrl } from 'proxy-from-env'; +import { inspect } from 'util'; +import { AbapServiceProvider } from './abap'; +import type { RefreshTokenChanged, ServiceInfo } from './auth'; import { - attachConnectionHandler, attachBasicAuthInterceptor, - attachUaaAuthInterceptor, - attachReentranceTicketAuthInterceptor + attachConnectionHandler, + attachReentranceTicketAuthInterceptor, + attachUaaAuthInterceptor } from './auth'; -import type { ProviderConfiguration } from './base/service-provider'; -import { ServiceProvider } from './base/service-provider'; import type { ODataService } from './base/odata-service'; -import { AbapServiceProvider } from './abap'; -import { inspect } from 'util'; import { TlsPatch } from './base/patchTls'; -import { getProxyForUrl } from 'proxy-from-env'; -import { type HttpsProxyAgentOptions, HttpsProxyAgent } from 'https-proxy-agent'; -import { HttpProxyAgent } from 'http-proxy-agent'; +import type { ProviderConfiguration } from './base/service-provider'; +import { ServiceProvider } from './base/service-provider'; type Class = new (...args: any[]) => T; @@ -98,6 +98,7 @@ function createInstance( */ providerConfig.validateStatus = (status) => status < 400; const instance = new ProviderType(providerConfig); + instance.defaults.headers = instance.defaults.headers ?? { common: {}, 'delete': {}, @@ -159,7 +160,9 @@ export enum AbapCloudEnvironment { /** Cloud Foundry OAuth 2.0 options */ export interface CFAOauthOptions { service: ServiceInfo; + refreshToken?: string; + refreshTokenChangedCb?: RefreshTokenChanged; } @@ -179,7 +182,7 @@ export interface AbapEmbeddedSteampunkOptions extends ReentranceTicketOptions { } /** Discriminated union of supported environments - {@link AbapCloudStandaloneOptions} and {@link AbapEmbeddedSteampunkOptions} */ -type AbapCloudOptions = AbapCloudStandaloneOptions | AbapEmbeddedSteampunkOptions; +export type AbapCloudOptions = AbapCloudStandaloneOptions | AbapEmbeddedSteampunkOptions; /** * Create an instance of an ABAP service provider for a Cloud ABAP system. @@ -197,8 +200,16 @@ export function createForAbapOnCloud(options: AbapCloudOptions & Partial { }); }); -describe('Use existing connection session', () => { - const attachUaaAuthInterceptorSpy = jest.spyOn(auth, 'attachUaaAuthInterceptor'); +describe('Should create new connections', () => { const attachReentranceTicketAuthInterceptorSpy = jest.spyOn(auth, 'attachReentranceTicketAuthInterceptor'); + test('abap service provider for cloud - credentials not provided, always use reentrance', async () => { + const getReentranceTicketSpy = jest + .spyOn(reentranceTicketAuth, 'getReentranceTicket') + .mockResolvedValueOnce({ reentranceTicket: 'reent_tecket_1234' }); + + const attachUaaAuthInterceptorSpy = jest.spyOn(auth, 'attachUaaAuthInterceptor'); + Uaa.prototype.getAccessToken = jest.fn(); + Uaa.prototype.getAccessTokenWithClientCredentials = jest.fn(); + const configForAbapOnCloudNoCreds = { + service: { + url: server, + uaa: { + clientid: 'ClientId', + clientsecret: 'ClientSecret', + url: server + } + } as any, + environment: AbapCloudEnvironment.Standalone + } as AbapCloudOptions & Partial; + nock(server) + .get('/sap/public/bc/icf/virtualhost') + .reply(200, { relatedUrls: { API: server, UI: server } }) + .get(AdtServices.DISCOVERY) + .replyWithFile(200, join(__dirname, 'mockResponses/discovery-1.xml')) + .get(AdtServices.ATO_SETTINGS) + .replyWithFile(200, join(__dirname, 'mockResponses/atoSettingsS4C.xml')) + .get('/sap/bc/adt/core/http/systeminformation') + .reply(200, { + userFullName: 'User FullName', + userName: 'userName01', + client: '100', + systemID: 'ABC01', + language: 'EN' + } as SystemInfo); + + const provider = createForAbapOnCloud(configForAbapOnCloudNoCreds); + expect(await provider.isAbapCloud()).toBe(true); + expect(await provider.user()).toBe('userName01'); + expect(Uaa.prototype.getAccessToken).toHaveBeenCalledTimes(0); + expect(Uaa.prototype.getAccessTokenWithClientCredentials).toHaveBeenCalledTimes(0); + expect(attachUaaAuthInterceptorSpy).toHaveBeenCalledTimes(0); + expect(attachReentranceTicketAuthInterceptorSpy).toHaveBeenCalledTimes(1); + expect(getReentranceTicketSpy).toHaveBeenCalledTimes(1); + }); + + test('abap service provider for cloud - credentials provided use UAA', async () => { + const attachUaaAuthInterceptorSpy = jest.spyOn(auth, 'attachUaaAuthInterceptor'); + const configForAbapOnCloud = { + service: { + log: console, + url: server, + uaa: { + username: 'TestUsername', + password: 'TestPassword', + clientid: 'ClientId', + clientsecret: 'ClientSecret', + url: server + } + }, + environment: AbapCloudEnvironment.Standalone + }; + nock(server) + .post('/oauth/token') + .reply(201, { access_token: 'accessToken', refresh_token: 'refreshToken' }) + .get('/userinfo') + .reply(200, { email: 'email', name: 'name' }); + + const provider = createForAbapOnCloud(configForAbapOnCloud as any); + expect(await provider.isAbapCloud()).toBe(false); + expect(await provider.user()).toBe('email'); + expect(Uaa.prototype.getAccessToken).toHaveBeenCalledTimes(0); + expect(Uaa.prototype.getAccessTokenWithClientCredentials).toHaveBeenCalledTimes(2); + expect(attachUaaAuthInterceptorSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('Use existing connection session (cookies)', () => { + const attachReentranceTicketAuthInterceptorSpy = jest.spyOn(auth, 'attachReentranceTicketAuthInterceptor'); + const existingCookieConfig: AxiosRequestConfig & Partial = { + baseURL: server, + cookies: 'sap-usercontext=sap-client=100;SAP_SESSIONID_Y05_100=abc' + }; + const existingCookieConfigForAbapOnCloudStandalone: AbapCloudOptions & Partial = { + service: { + url: server + } as any, + cookies: 'sap-usercontext=sap-client=100;SAP_SESSIONID_Y05_100=abc', + environment: AbapCloudEnvironment.Standalone + }; + const existingCookieConfigForAbapOnCloudEmbeddedSteampunk: AbapCloudOptions & Partial = { + url: server, + cookies: 'sap-usercontext=sap-client=100;SAP_SESSIONID_X01_100=abc', + environment: AbapCloudEnvironment.EmbeddedSteampunk + }; + beforeAll(() => { nock.disableNetConnect(); }); beforeEach(() => { nock.cleanAll(); - attachUaaAuthInterceptorSpy.mockRestore(); attachReentranceTicketAuthInterceptorSpy.mockRestore(); - - Uaa.prototype.getAccessToken = jest.fn(); - Uaa.prototype.getAccessTokenWithClientCredentials = jest.fn(); }); afterAll(() => { @@ -403,7 +468,7 @@ describe('Use existing connection session', () => { expect(provider.cookies.toString()).toBe('sap-usercontext=sap-client=100; SAP_SESSIONID_Y05_100=abc'); }); - test('abap service provider for cloud (standalone)', async () => { + test('abap service provider for cloud (standalone) - reentrance', async () => { nock(server) .get(AdtServices.DISCOVERY) .replyWithFile(200, join(__dirname, 'mockResponses/discovery-1.xml')) @@ -412,78 +477,43 @@ describe('Use existing connection session', () => { const provider = createForAbapOnCloud(existingCookieConfigForAbapOnCloudStandalone as any); expect(provider.cookies.toString()).toBe('sap-usercontext=sap-client=100; SAP_SESSIONID_Y05_100=abc'); - expect(await provider.isAbapCloud()).toBe(false); - expect(attachUaaAuthInterceptorSpy).toHaveBeenCalledTimes(0); - expect(Uaa.prototype.getAccessToken).toHaveBeenCalledTimes(0); - expect(Uaa.prototype.getAccessTokenWithClientCredentials).toHaveBeenCalledTimes(0); + expect(await provider.isAbapCloud()).toBe(true); + expect(attachReentranceTicketAuthInterceptorSpy).toHaveBeenCalledTimes(0); }); - test('abap service provider for cloud (embedded steampunk)', async () => { + test('abap service provider for cloud (embedded steampunk) - reentrance', async () => { nock(server) .get(AdtServices.DISCOVERY) .replyWithFile(200, join(__dirname, 'mockResponses/discovery-1.xml')) .get(AdtServices.ATO_SETTINGS) .replyWithFile(200, join(__dirname, 'mockResponses/atoSettingsS4C.xml')); - const provider = createForAbapOnCloud(existingCookieConfigForAbapOnCloudEmbeddedSteampunk as any); + const provider = createForAbapOnCloud(existingCookieConfigForAbapOnCloudEmbeddedSteampunk); expect(provider.cookies.toString()).toBe('sap-usercontext=sap-client=100; SAP_SESSIONID_X01_100=abc'); expect(attachReentranceTicketAuthInterceptorSpy).toHaveBeenCalledTimes(0); }); - test('abap service provider for cloud - require authentication', async () => { + test('abap service provider for cloud - active session', async () => { nock(server) .get(AdtServices.DISCOVERY) .replyWithFile(200, join(__dirname, 'mockResponses/discovery-1.xml')) .get(AdtServices.ATO_SETTINGS) .replyWithFile(200, join(__dirname, 'mockResponses/atoSettingsS4C.xml')) - .get('/userinfo') - .reply(200, { email: 'emailTest', name: 'nameTest' }); + .get('/sap/bc/adt/core/http/systeminformation') + .reply(200, { + userFullName: 'User FullName', + userName: 'userName01', + client: '100', + systemID: 'ABC01', + language: 'EN' + } as SystemInfo); - const cloneObj = cloneDeep(configForAbapOnCloud); - delete cloneObj.service.uaa.username; - const provider = createForAbapOnCloud(cloneObj as any); + const config = cloneDeep(existingCookieConfigForAbapOnCloudEmbeddedSteampunk); + const provider = createForAbapOnCloud(config as any); expect(await provider.isAbapCloud()).toBe(true); - expect(await provider.user()).toBe('emailTest'); - expect(Uaa.prototype.getAccessToken).toHaveBeenCalledTimes(3); - expect(Uaa.prototype.getAccessTokenWithClientCredentials).toHaveBeenCalledTimes(0); - }); - - test('abap service provider for cloud - with authentication provided', async () => { - nock(server) - .post('/oauth/token') - .reply(201, { access_token: 'accessToken', refresh_token: 'refreshToken' }) - .get('/userinfo') - .reply(200, { email: 'email', name: 'name' }); - - const configForAbapOnCloudWithAuthentication = cloneDeep(configForAbapOnCloud); - configForAbapOnCloudWithAuthentication.service = { - log: console, - url: server, - uaa: { - username: 'TestUsername', - password: 'TestPassword', - clientid: 'ClientId', - clientsecret: 'ClientSecret', - url: server - } - }; - const provider = createForAbapOnCloud(configForAbapOnCloudWithAuthentication as any); - expect(await provider.isAbapCloud()).toBe(false); - expect(await provider.user()).toBe('email'); - expect(Uaa.prototype.getAccessToken).toHaveBeenCalledTimes(0); - expect(Uaa.prototype.getAccessTokenWithClientCredentials).toHaveBeenCalledTimes(2); - }); - - it.each([ - { remove: 'clientid', errorStr: 'Client ID missing' }, - { remove: 'clientsecret', errorStr: 'Client Secret missing' }, - { remove: 'url', errorStr: 'UAA URL missing' } - ])('Fail with error: $errorStr', ({ remove, errorStr }) => { - const cloneObj = cloneDeep(configForAbapOnCloud); - delete cloneObj.service.uaa[remove]; - expect(() => { - createForAbapOnCloud(cloneObj as any); - }).toThrow(errorStr); + expect(await provider.user()).toBe('userName01'); + // Cookies with session already set so not expected to add an auth interceptor + expect(attachReentranceTicketAuthInterceptorSpy).toHaveBeenCalledTimes(0); }); }); diff --git a/packages/axios-extension/test/auth/index.test.ts b/packages/axios-extension/test/auth/index.test.ts index 2a6ec466b3d..00a96e2d4ec 100644 --- a/packages/axios-extension/test/auth/index.test.ts +++ b/packages/axios-extension/test/auth/index.test.ts @@ -7,6 +7,7 @@ import * as rt from '../../src/auth/reentrance-ticket'; import type { InternalAxiosRequestConfig } from 'axios'; import { AxiosHeaders } from 'axios'; import { WebIDEUsage as WebIDEUsageType, type Destination } from '@sap-ux/btp-utils'; +import type { ABAPVirtualHostProvider } from '../../src/auth/reentrance-ticket/abap-virtual-host-provider'; describe('getReentranceTicketAuthInterceptor', () => { const getReentranceTicketSpy = jest.spyOn(rt, 'getReentranceTicket'); @@ -23,18 +24,24 @@ describe('getReentranceTicketAuthInterceptor', () => { expect(request.headers.MYSAPSSO2).toBe(REENTRANCE_TICKET_VALUE); }); - it('changes provider baseURL if different to API host', async () => { - const API_URL = 'api_url'; - const ORIGINAL_BASE_URL = 'base_url.example'; - getReentranceTicketSpy.mockResolvedValueOnce({ reentranceTicket: 'foo', apiUrl: API_URL }); - const provider = new ServiceProvider({ baseURL: ORIGINAL_BASE_URL }); + it('Should update provider baseURL to api host', async () => { + const API_ORIGIN = 'http://api_host.example'; + const ORIGINAL_ORIGIN = 'http://base_url.example'; + const backendMock = { + apiHostname: jest.fn().mockReturnValue(API_ORIGIN) + }; + getReentranceTicketSpy.mockResolvedValueOnce({ + reentranceTicket: 'foo', + backend: backendMock as unknown as ABAPVirtualHostProvider + }); + const provider = new ServiceProvider({ baseURL: ORIGINAL_ORIGIN }); const interceptor = getReentranceTicketAuthInterceptor({ provider, ejectCallback: () => 0 }); - expect(provider.defaults.baseURL).toBe(ORIGINAL_BASE_URL); + expect(provider.defaults.baseURL).toBe(ORIGINAL_ORIGIN); await interceptor({ headers: new AxiosHeaders() }); - expect(provider.defaults.baseURL).toBe(API_URL); + expect(provider.defaults.baseURL).toBe(API_ORIGIN); }); it('calls eject after running once', async () => { @@ -74,8 +81,8 @@ describe('attachUaaAuthInterceptor', () => { const callback = jest.fn(); it('check interceptor request handlers length', () => { - expect(Object.keys(provider.interceptors.request['handlers']).length).toEqual(2); + expect(Object.keys((provider.interceptors.request as any)['handlers']).length).toEqual(2); attachUaaAuthInterceptor(provider, service, refreshToken, callback); - expect(Object.keys(provider.interceptors.request['handlers']).length).toEqual(3); + expect(Object.keys((provider.interceptors.request as any)['handlers']).length).toEqual(3); }); }); diff --git a/packages/axios-extension/test/auth/reentrance-ticket/abap-system.test.ts b/packages/axios-extension/test/auth/reentrance-ticket/abap-system.test.ts deleted file mode 100644 index aa7316a9105..00000000000 --- a/packages/axios-extension/test/auth/reentrance-ticket/abap-system.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { ABAPSystem } from '../../../src/auth/reentrance-ticket/abap-system'; - -describe('ABAPSystem', () => { - describe('uiHostname()', () => { - it('removes -api from the first label of hostname, multiple labels', () => { - expect(new ABAPSystem('http://first-api.second.example/some/path?foo=bar&baz=quux').uiHostname()).toBe( - 'http://first.second.example' - ); - }); - - it('removes -api from the first label of hostname, single label', () => { - expect(new ABAPSystem('http://first-api/some/path?foo=bar&baz=quux').uiHostname()).toBe('http://first'); - }); - it('removes -api from the first label of hostname, multiple labels, with port', () => { - expect( - new ABAPSystem('http://first-api.second.example:50000/some/path?foo=bar&baz=quux').uiHostname() - ).toBe('http://first.second.example:50000'); - }); - - it('removes -api from the first label of hostname, single label, with port', () => { - expect(new ABAPSystem('http://first-api:50000/some/path?foo=bar&baz=quux').uiHostname()).toBe( - 'http://first:50000' - ); - }); - - it('multiple labels, no -api, hostname unchanged', () => { - expect(new ABAPSystem('http://first.second.example/some/path?foo=bar&baz=quux').uiHostname()).toBe( - 'http://first.second.example' - ); - }); - - it('single label, no -api, hostname unchanged', () => { - expect(new ABAPSystem('http://first/some/path?foo=bar&baz=quux').uiHostname()).toBe('http://first'); - }); - it('multiple labels, with port number, no -api, hostname unchanged', () => { - expect(new ABAPSystem('http://first.second.example:50000/some/path?foo=bar&baz=quux').uiHostname()).toBe( - 'http://first.second.example:50000' - ); - }); - - it('single label, with port number, no -api, hostname unchanged', () => { - expect(new ABAPSystem('http://first:50000/some/path?foo=bar&baz=quux').uiHostname()).toBe( - 'http://first:50000' - ); - }); - }); - describe('apiHostname()', () => { - it('adds -api to the first label of hostname, multiple labels', () => { - expect(new ABAPSystem('http://first.second.example/some/path?foo=bar&baz=quux').apiHostname()).toBe( - 'http://first-api.second.example' - ); - }); - - it('adds -api to the first label of hostname, single label', () => { - expect(new ABAPSystem('http://first-api/some/path?foo=bar&baz=quux').apiHostname()).toBe( - 'http://first-api' - ); - }); - it('adds -api to the first label of hostname, multiple labels, with port', () => { - expect( - new ABAPSystem('http://first-api.second.example:50000/some/path?foo=bar&baz=quux').apiHostname() - ).toBe('http://first-api.second.example:50000'); - }); - - it('adds -api to the first label of hostname, single label, with port', () => { - expect(new ABAPSystem('http://first-api:50000/some/path?foo=bar&baz=quux').apiHostname()).toBe( - 'http://first-api:50000' - ); - }); - - it('multiple labels, has -api, hostname unchanged', () => { - expect(new ABAPSystem('http://first-api.second.example/some/path?foo=bar&baz=quux').apiHostname()).toBe( - 'http://first-api.second.example' - ); - }); - - it('single label, has -api, hostname unchanged', () => { - expect(new ABAPSystem('http://first-api/some/path?foo=bar&baz=quux').apiHostname()).toBe( - 'http://first-api' - ); - }); - it('multiple labels, with port number, has -api, hostname unchanged', () => { - expect( - new ABAPSystem('http://first-api.second.example:50000/some/path?foo=bar&baz=quux').apiHostname() - ).toBe('http://first-api.second.example:50000'); - }); - - it('single label, with port number, has -api, hostname unchanged', () => { - expect(new ABAPSystem('http://first-api:50000/some/path?foo=bar&baz=quux').apiHostname()).toBe( - 'http://first-api:50000' - ); - }); - }); - describe('logoffUrl()', () => { - it('uses UI hostname, given API url', () => { - const url = 'http://first-api.second.example/some/path?foo=bar&baz=quux'; - const logoffURL = new URL(new ABAPSystem(url).logoffUrl()); - expect(logoffURL.origin).toBe(new ABAPSystem(url).uiHostname()); - }); - - it('uses UI hostname, given a non-API url', () => { - const url = 'http://first.second.example/some/path?foo=bar&baz=quux'; - const logoffURL = new URL(new ABAPSystem(url).logoffUrl()); - expect(logoffURL.origin).toBe(new ABAPSystem(url).uiHostname()); - }); - }); -}); diff --git a/packages/axios-extension/test/auth/reentrance-ticket/index.test.ts b/packages/axios-extension/test/auth/reentrance-ticket/index.test.ts index d64b5cb67e5..0c94dbbdea5 100644 --- a/packages/axios-extension/test/auth/reentrance-ticket/index.test.ts +++ b/packages/axios-extension/test/auth/reentrance-ticket/index.test.ts @@ -4,18 +4,20 @@ import type http from 'http'; import { getReentranceTicket } from '../../../src/auth/reentrance-ticket'; import { NullTransport, ToolsLogger } from '@sap-ux/logger'; import open = require('open'); +import nock = require('nock'); jest.mock('open'); const mockOpen = jest.mocked(open); describe('getReentranceTicket()', () => { + const serverOrigin = 'http://some_url.example'; const REDIRECT_URL = 'http://redirect_url.example'; const serverListenSpy = jest.fn(); beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(redirect, 'setupRedirectHandling').mockImplementation(({ resolve }) => { - process.nextTick(() => resolve()); + jest.spyOn(redirect, 'setupRedirectHandling').mockImplementation(({ resolve, backend }) => { + resolve({ reentranceTicket: 'some_ticket', backend }); return { server: { listen: serverListenSpy, @@ -26,30 +28,34 @@ describe('getReentranceTicket()', () => { redirectUrl: () => REDIRECT_URL }; }); + nock(serverOrigin) + .get('/sap/public/bc/icf/virtualhost') + .reply(200, { relatedUrls: { API: serverOrigin, UI: serverOrigin } }); }); it('sets up a server to listen for the redirect', async () => { await getReentranceTicket({ - backendUrl: 'http://some_url.example', + backendUrl: serverOrigin, logger: new ToolsLogger({ transports: [new NullTransport()] }) }); expect(serverListenSpy).toHaveBeenCalledTimes(1); }); it("attempts to open URL in user's default browser for SAML login", async () => { - await getReentranceTicket({ - backendUrl: 'http://some_url.example', + const result = await getReentranceTicket({ + backendUrl: serverOrigin, logger: new ToolsLogger({ transports: [new NullTransport()] }) }); expect(mockOpen).toHaveBeenCalledWith(expect.stringContaining(REDIRECT_URL)); // default SCENARIO is FTO1 if none provided via env variable expect(mockOpen).toHaveBeenCalledWith(expect.stringContaining('FTO1')); + expect(result).toEqual({ reentranceTicket: 'some_ticket', backend: expect.any(Object) }); }); it('Sets scenario from env variable', async () => { process.env.FIORI_TOOLS_SCENARIO = 'MYSCENARIO'; await getReentranceTicket({ - backendUrl: 'http://some_url.example', + backendUrl: serverOrigin, logger: new ToolsLogger({ transports: [new NullTransport()] }) }); expect(mockOpen).toHaveBeenCalledWith(expect.stringContaining('MYSCENARIO')); diff --git a/packages/axios-extension/test/auth/reentrance-ticket/redirect.test.ts b/packages/axios-extension/test/auth/reentrance-ticket/redirect.test.ts index 5176948d78a..a399e0c940c 100644 --- a/packages/axios-extension/test/auth/reentrance-ticket/redirect.test.ts +++ b/packages/axios-extension/test/auth/reentrance-ticket/redirect.test.ts @@ -1,10 +1,11 @@ import http from 'http'; import type { SetupRedirectOptions } from '../../../src/auth/reentrance-ticket/redirect'; import { setupRedirectHandling } from '../../../src/auth/reentrance-ticket/redirect'; -import { ABAPSystem } from '../../../src/auth/reentrance-ticket/abap-system'; +import { ABAPVirtualHostProvider } from '../../../src/auth/reentrance-ticket/abap-virtual-host-provider'; import { NullTransport, ToolsLogger } from '@sap-ux/logger'; import { ConnectionError, TimeoutError } from '../../../src/auth'; import request from 'supertest'; +import nock = require('nock'); describe('setupRedirectHandling()', () => { beforeEach(() => { @@ -22,7 +23,7 @@ describe('setupRedirectHandling()', () => { resolve: jest.fn(), reject: jest.fn(), timeout: 1, - backend: new ABAPSystem('http://backend'), + backend: options.backend ? options.backend : new ABAPVirtualHostProvider('http://backend'), logger: new ToolsLogger({ transports: [new NullTransport()] }), @@ -58,6 +59,9 @@ describe('setupRedirectHandling()', () => { }); it('calls resolve() with the reentrance ticket', async () => { + const uiHostNameSpy = jest + .spyOn(ABAPVirtualHostProvider.prototype, `uiHostname`) + .mockResolvedValue('http://backend'); const rejectCallback = jest.fn(); const resolveCallback = jest.fn(); const REENTRANCE_TICKET = 'reentrance_ticket'; @@ -69,22 +73,27 @@ describe('setupRedirectHandling()', () => { expect(resolveCallback).toHaveBeenCalledWith(expect.objectContaining({ reentranceTicket: REENTRANCE_TICKET })); }); - it('calls resolve() with the with API URL', async () => { + it('calls resolve() with the with backend (virtual host provider)', async () => { + const backedUrl = 'https://backend'; + const backendUiHost = 'https://backend-ui-host'; const rejectCallback = jest.fn(); const resolveCallback = jest.fn(); - const backedUrl = 'https://backend'; + const uiHostNameSpy = jest + .spyOn(ABAPVirtualHostProvider.prototype, `uiHostname`) + .mockResolvedValue(backendUiHost); + const REENTRANCE_TICKET = 'reentrance_ticket'; const { server, redirectUrl } = setup({ resolve: resolveCallback, reject: rejectCallback, - backend: new ABAPSystem(backedUrl) + backend: new ABAPVirtualHostProvider(backedUrl) }); await request(server).get(`${redirectPath(redirectUrl)}?reentrance-ticket=${REENTRANCE_TICKET}`); expect(rejectCallback).not.toHaveBeenCalled(); expect(resolveCallback).toHaveBeenCalledTimes(1); - expect(resolveCallback).toHaveBeenCalledWith(expect.objectContaining({ apiUrl: backedUrl + '-api' })); + expect(uiHostNameSpy).toHaveBeenCalled(); }); it('calls reject() when reentrance ticket is missing', async () => { @@ -95,7 +104,7 @@ describe('setupRedirectHandling()', () => { const { server, redirectUrl } = setup({ resolve: resolveCallback, reject: rejectCallback, - backend: new ABAPSystem(backedUrl) + backend: new ABAPVirtualHostProvider(backedUrl) }); await request(server).get(redirectPath(redirectUrl)); diff --git a/packages/axios-extension/test/auth/reentrance-ticket/virtual-host-provider.test.ts b/packages/axios-extension/test/auth/reentrance-ticket/virtual-host-provider.test.ts new file mode 100644 index 00000000000..7e412473196 --- /dev/null +++ b/packages/axios-extension/test/auth/reentrance-ticket/virtual-host-provider.test.ts @@ -0,0 +1,32 @@ +import nock from 'nock'; +import { ABAPVirtualHostProvider } from '../../../src/auth/reentrance-ticket/abap-virtual-host-provider'; + +describe('ABAPVirtualHostProvider', () => { + const backendOrigin = 'https://backend.com'; + const virtualHosts = { + relatedUrls: { + API: 'https://some.api.host', + UI: 'https://some.web.host/ui' + } + }; + + beforeEach(() => { + nock(backendOrigin) + .get('/sap/public/bc/icf/virtualhost') + .reply(200, virtualHosts) + .get('/sap/public/bc/icf/logoff') + .reply(200); + }); + + it('Should retrieve ui and api host name from virtual hosts endpoint', async () => { + const vhp = new ABAPVirtualHostProvider(`${backendOrigin}/some/path?foo=bar&baz=quux`); + expect(await vhp.uiHostname()).toEqual('https://some.web.host'); + expect(await vhp.apiHostname()).toEqual(virtualHosts.relatedUrls.API); + }); + + it('should use virtual ui host name for logoff', async () => { + const vhp = new ABAPVirtualHostProvider(`${backendOrigin}/some/path?foo=bar&baz=quux`); + const logoffURL = new URL(await vhp.logoffUrl()); + expect(logoffURL.origin).toEqual('https://some.web.host'); + }); +}); diff --git a/packages/backend-proxy-middleware/src/base/proxy.ts b/packages/backend-proxy-middleware/src/base/proxy.ts index ea6097b6d1e..76213f9237e 100644 --- a/packages/backend-proxy-middleware/src/base/proxy.ts +++ b/packages/backend-proxy-middleware/src/base/proxy.ts @@ -1,32 +1,31 @@ -import { HttpsProxyAgent } from 'https-proxy-agent'; -import type { ServerOptions } from 'http-proxy'; -import type { RequestHandler, Options } from 'http-proxy-middleware'; -import { createProxyMiddleware } from 'http-proxy-middleware'; -import i18n from 'i18next'; -import type { ClientRequest, IncomingMessage, ServerResponse } from 'http'; -import { ToolsLogger, type Logger, UI5ToolingTransport } from '@sap-ux/logger'; import { AbapCloudEnvironment, createForAbapOnCloud } from '@sap-ux/axios-extension'; +import type { ServiceInfo } from '@sap-ux/btp-utils'; import { - isAppStudio, - getDestinationUrlForAppStudio, + BAS_DEST_INSTANCE_CRED_HEADER, getCredentialsForDestinationService, - listDestinations, + getDestinationUrlForAppStudio, + isAppStudio, isFullUrlDestination, - BAS_DEST_INSTANCE_CRED_HEADER + listDestinations } from '@sap-ux/btp-utils'; -import type { ServiceInfo } from '@sap-ux/btp-utils'; -import type { BackendConfig, DestinationBackendConfig, LocalBackendConfig } from './types'; +import { ToolsLogger, UI5ToolingTransport, type Logger } from '@sap-ux/logger'; +import type { ClientRequest, IncomingMessage, ServerResponse } from 'http'; +import type { ServerOptions } from 'http-proxy'; +import type { Options, RequestHandler } from 'http-proxy-middleware'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import i18n from 'i18next'; import translations from './i18n.json'; - +import type { BackendConfig, DestinationBackendConfig, LocalBackendConfig } from './types'; import type { ApiHubSettings, ApiHubSettingsKey, ApiHubSettingsService, BackendSystem } from '@sap-ux/store'; import { AuthenticationType, BackendSystemKey, getService } from '@sap-ux/store'; -import { updateProxyEnv } from './config'; +import type connect from 'connect'; +import type { Request } from 'express'; +import type { Socket } from 'node:net'; import type { Url } from 'node:url'; -import { addOptionsForEmbeddedBSP } from '../ext/bsp'; import { getProxyForUrl } from 'proxy-from-env'; -import type { Socket } from 'node:net'; -import type { Request } from 'express'; -import type connect from 'connect'; +import { addOptionsForEmbeddedBSP } from '../ext/bsp'; +import { updateProxyEnv } from './config'; export type EnhancedIncomingMessage = (IncomingMessage & Pick) | connect.IncomingMessage; @@ -264,16 +263,16 @@ export async function enhanceConfigsForDestination( * * @param proxyOptions reference to a proxy options object that the function will enhance * @param system backend system information (most likely) read from the store - * @param oAuthRequired if true then the OAuth flow is triggered to get cookies + * @param authType determines the authentication protocol to be used * @param tokenChangedCallback function to call if a new refreshToken is available */ export async function enhanceConfigForSystem( proxyOptions: Options & { headers: object }, system: BackendSystem | undefined, - oAuthRequired: boolean | undefined, + authType: AuthenticationType, tokenChangedCallback: (refreshToken?: string) => void ): Promise { - if (oAuthRequired) { + if (authType === AuthenticationType.OAuth2RefreshToken) { if (system?.serviceKeys) { const provider = createForAbapOnCloud({ environment: AbapCloudEnvironment.Standalone, @@ -284,9 +283,9 @@ export async function enhanceConfigForSystem( // sending a request to the backend to get token await provider.getAtoInfo(); } else { - throw new Error('Cannot connect to ABAP Environment on BTP without service keys.'); + throw new Error('Cannot connect to ABAP Environment on BTP using OAuth without service keys.'); } - } else if (system?.authenticationType === AuthenticationType.ReentranceTicket) { + } else if (system && authType === AuthenticationType.ReentranceTicket) { const provider = createForAbapOnCloud({ ignoreCertErrors: proxyOptions.secure === false, environment: AbapCloudEnvironment.EmbeddedSteampunk, @@ -336,7 +335,6 @@ export async function generateProxyMiddlewareOptions( target: backend.url, pathRewrite: PathRewriters.getPathRewrite(backend, logger) }; - // overwrite url if running in AppStudio if (isAppStudio()) { const destBackend = backend as DestinationBackendConfig; @@ -346,39 +344,7 @@ export async function generateProxyMiddlewareOptions( logger.info('Using destination: ' + destBackend.destination); } } else { - const localBackend = backend as LocalBackendConfig; - // check if system credentials are stored in the store - try { - const systemStore = await getService({ logger, entityName: 'system' }); - const system = (await systemStore.read( - new BackendSystemKey({ url: localBackend.url, client: localBackend.client }) - )) ?? { - name: '', - url: localBackend.url, - authenticationType: localBackend.authenticationType - }; - await enhanceConfigForSystem( - proxyOptions, - system, - backend.scp, - (refreshToken?: string, accessToken?: string) => { - if (refreshToken) { - logger.info('Updating refresh token for: ' + localBackend.url); - systemStore.write({ ...system, refreshToken }).catch((error) => logger.error(error)); - } - - if (accessToken) { - logger.info('Setting access token'); - proxyOptions.headers['authorization'] = `bearer ${accessToken}`; - } else { - logger.warn('Setting of access token failed.'); - } - } - ); - } catch (error) { - logger.warn('Accessing the credentials store failed.'); - logger.debug(error as object); - } + await updateProxyConfigFromStore(backend, logger, proxyOptions); } if (!proxyOptions.auth && process.env.FIORI_TOOLS_USER && process.env.FIORI_TOOLS_PASSWORD) { @@ -411,6 +377,55 @@ export async function generateProxyMiddlewareOptions( return proxyOptions; } +/** + * Determine the correct authentication configuration for connections from a non-BAS platform. + * + * @param backend the backend config loaded from the yaml config + * @param logger a logger instance + * @param proxyOptions additional proxy header, request and response settings + */ +async function updateProxyConfigFromStore( + backend: BackendConfig, + logger: ToolsLogger, + proxyOptions: Options> & { headers: object } +) { + const localBackend = backend as LocalBackendConfig; + // check if system credentials are stored in the store + try { + const systemStore = await getService({ logger, entityName: 'system' }); + const system = (await systemStore.read( + new BackendSystemKey({ url: localBackend.url, client: localBackend.client }) + )) ?? { + name: '', + url: localBackend.url, + authenticationType: localBackend.authenticationType + }; + // Auth type is determined from app config as we may have multiple stored systems with the same url/client using different auth types + await enhanceConfigForSystem( + proxyOptions, + system, + localBackend.authenticationType ?? + (localBackend.scp ? AuthenticationType.OAuth2RefreshToken : AuthenticationType.Basic), + (refreshToken?: string, accessToken?: string) => { + if (refreshToken) { + logger.info('Updating refresh token for: ' + localBackend.url); + systemStore.write({ ...system, refreshToken }).catch((error) => logger.error(error)); + } + + if (accessToken) { + logger.info('Setting access token'); + proxyOptions.headers['authorization'] = `bearer ${accessToken}`; + } else { + logger.warn('Setting of access token failed.'); + } + } + ); + } catch (error) { + logger.warn('Accessing the credentials store failed.'); + logger.debug(error as object); + } +} + /** * Generate an instance of the proxy middleware based on the input. * diff --git a/packages/backend-proxy-middleware/test/base/proxy.test.ts b/packages/backend-proxy-middleware/test/base/proxy.test.ts index 72108f6dad0..ea3e95aca31 100644 --- a/packages/backend-proxy-middleware/test/base/proxy.test.ts +++ b/packages/backend-proxy-middleware/test/base/proxy.test.ts @@ -308,7 +308,7 @@ describe('proxy', () => { test('simple system', async () => { const proxyOptions: OptionsWithHeaders = { headers: {} }; - await enhanceConfigForSystem({ ...proxyOptions }, system, false, jest.fn()); + await enhanceConfigForSystem({ ...proxyOptions }, system, 'basic', jest.fn()); expect(proxyOptions).toEqual(proxyOptions); }); @@ -321,7 +321,7 @@ describe('proxy', () => { }); try { - await enhanceConfigForSystem({ headers: {} }, system, true, jest.fn()); + await enhanceConfigForSystem({ headers: {} }, system, 'oauth2', jest.fn()); fail('Should have thrown an error because no service keys have been provided.'); } catch (error) { expect(error).toBeDefined(); @@ -334,7 +334,7 @@ describe('proxy', () => { refreshToken: '~token' }; const callback = jest.fn(); - await enhanceConfigForSystem(proxyOptions, cloudSystem, true, callback); + await enhanceConfigForSystem(proxyOptions, cloudSystem, 'oauth2', callback); expect(mockCreateForAbapOnCloud).toHaveBeenCalledWith({ environment: AbapCloudEnvironment.Standalone, service: cloudSystem.serviceKeys, @@ -351,13 +351,13 @@ describe('proxy', () => { }; // provided from config - await enhanceConfigForSystem(proxyOptions, { ...system, ...creds }, false, jest.fn()); + await enhanceConfigForSystem(proxyOptions, { ...system, ...creds }, 'basic', jest.fn()); expect(proxyOptions.auth).toBe(`${creds.username}:${creds.password}`); // provided from env variables process.env.FIORI_TOOLS_USER = creds.username; process.env.FIORI_TOOLS_PASSWORD = creds.password; - await enhanceConfigForSystem(proxyOptions, system, false, jest.fn()); + await enhanceConfigForSystem(proxyOptions, system, 'basic', jest.fn()); expect(proxyOptions.auth).toBe(`${creds.username}:${creds.password}`); }); @@ -375,7 +375,7 @@ describe('proxy', () => { ...system, authenticationType: AuthenticationType.ReentranceTicket }, - false, + 'reentranceTicket', jest.fn() ); diff --git a/packages/feature-toggle/src/constants.ts b/packages/feature-toggle/src/constants.ts index 66f168ce002..8320121f75d 100644 --- a/packages/feature-toggle/src/constants.ts +++ b/packages/feature-toggle/src/constants.ts @@ -18,8 +18,7 @@ export const tokenToggleGuid: ExtensionConfigKeys = { 'sap.ux.help.testBetaFeatures.enableFioriAIAppModeler': '165a0e31-35ea-4bee-8d47-b8593435a82g', 'sap.ux.help.testBetaFeatures.enableFioriAIRapGeneration': '165a0e31-35ea-4bee-8d47-b8593435a82h', 'sap.ux.applicationModeler.testBetaFeatures.manifestEditor': true, - 'sap.ux.appGenerator.testBetaFeatures.newAnnotationAPI': true, - 'sap.ux.appGenerator.testBetaFeatures.disableBtpServiceKeyAuth': false + 'sap.ux.appGenerator.testBetaFeatures.newAnnotationAPI': true } as ExtensionConfigKeys; export const FeatureToggleKey = 'testBetaFeatures'; diff --git a/packages/fiori-app-sub-generator/src/fiori-app-generator/transforms.ts b/packages/fiori-app-sub-generator/src/fiori-app-generator/transforms.ts index b42f87c72e9..e88fad958b5 100644 --- a/packages/fiori-app-sub-generator/src/fiori-app-generator/transforms.ts +++ b/packages/fiori-app-sub-generator/src/fiori-app-generator/transforms.ts @@ -241,17 +241,16 @@ export async function transformState( if ( service.destinationAuthType === DestinationAuthType.SAML_ASSERTION || service.connectedSystem?.destination?.Authentication === DestinationAuthType.SAML_ASSERTION || - AuthenticationType.ReentranceTicket === service.connectedSystem?.backendSystem?.authenticationType - ) { - appConfig.service.previewSettings = { authenticationType: AuthenticationType.ReentranceTicket }; - } else if ( + AuthenticationType.ReentranceTicket === service.connectedSystem?.backendSystem?.authenticationType || + // Apps generated with stored service keys (legacy) will use re-entrance tickets for connectivity + // New stored systems will only use re-entrance service.connectedSystem?.backendSystem?.serviceKeys || - // If 'cloud' write `scp` property to yamls to enable preview on VSCode (using oAuth) + // If 'cloud' this will enable preview on VSCode (using re-entrance) for app portability (getHostEnvironment() === hostEnvironment.bas && service.connectedSystem?.destination && isAbapEnvironmentOnBtp(service.connectedSystem?.destination)) ) { - appConfig.service.previewSettings = { scp: true }; + appConfig.service.previewSettings = { authenticationType: AuthenticationType.ReentranceTicket }; } else if (service.apiHubConfig) { appConfig.service.previewSettings = { apiHub: true }; } diff --git a/packages/fiori-app-sub-generator/src/fiori-app-generator/writing.ts b/packages/fiori-app-sub-generator/src/fiori-app-generator/writing.ts index e504f06ffd3..3d2e55c8f85 100644 --- a/packages/fiori-app-sub-generator/src/fiori-app-generator/writing.ts +++ b/packages/fiori-app-sub-generator/src/fiori-app-generator/writing.ts @@ -4,7 +4,7 @@ import type { Editor } from 'mem-fs-editor'; import { basename, join } from 'node:path'; import type { ApiHubConfig, State } from '../types'; import { DEFAULT_CAP_HOST } from '../types'; -import { getLaunchText, getReadMeDataSourceLabel, isBTPHosted, t } from '../utils'; +import { getLaunchText, getReadMeDataSourceLabel, isAbapCloud, t } from '../utils'; /** * Writes app related information files - README.md & .appGenInfo.json. @@ -36,7 +36,7 @@ export async function writeAppGenInfoFiles( const datasourceLabel = getReadMeDataSourceLabel( service.source, - isBTPHosted(service.connectedSystem), + isAbapCloud(service.connectedSystem), service.apiHubConfig?.apiHubType ); diff --git a/packages/fiori-app-sub-generator/src/translations/fioriAppSubGenerator.i18n.json b/packages/fiori-app-sub-generator/src/translations/fioriAppSubGenerator.i18n.json index e2392b34cfd..cd25d204f76 100644 --- a/packages/fiori-app-sub-generator/src/translations/fioriAppSubGenerator.i18n.json +++ b/packages/fiori-app-sub-generator/src/translations/fioriAppSubGenerator.i18n.json @@ -30,9 +30,8 @@ "filterEntityType": "Filter Entity Type", "navigationEntity": "Navigation Entity", "sapSystemType": { - "abapOnBtp": "ABAP Environment on SAP Business Technology Platform", - "s4hc": "SAP S/4HANA Cloud Environment", - "onPrem": "ABAP On-Premise" + "onPrem": "ABAP On-Premise", + "abapCloud": "ABAP Cloud" }, "datasourceType": { "sapSystem": "SAP System", diff --git a/packages/fiori-app-sub-generator/src/types/constants.ts b/packages/fiori-app-sub-generator/src/types/constants.ts index 181fda4d144..8ba781c11a0 100644 --- a/packages/fiori-app-sub-generator/src/types/constants.ts +++ b/packages/fiori-app-sub-generator/src/types/constants.ts @@ -5,9 +5,8 @@ export const LEGACY_CAP_TYPE_NODE = 'capNode'; export const LEGACY_CAP_TYPE_JAVA = 'capJava'; export const enum SapSystemSourceType { - SCP = 'abapOnBtp', - ON_PREM = 'onPrem', - S4HC = 's4hc' + ABAP_CLOUD = 'abapCloud', + ON_PREM = 'onPrem' } export const PLATFORMS = { diff --git a/packages/fiori-app-sub-generator/src/utils/common.ts b/packages/fiori-app-sub-generator/src/utils/common.ts index 24a83e99d42..f49b6e353d1 100644 --- a/packages/fiori-app-sub-generator/src/utils/common.ts +++ b/packages/fiori-app-sub-generator/src/utils/common.ts @@ -27,6 +27,7 @@ import { ApiHubType, SapSystemSourceType, FloorplanFE, minUi5VersionForPageBuild import { minSupportedUi5Version, minSupportedUi5VersionV4 } from '../types/constants'; import { type Floorplan, FloorplanAttributes, FloorplanFF } from '../types/external'; import { t } from './i18n'; +import { getBackendSystemType } from '@sap-ux/store'; /** * Parse the specified edmx string for validitiy and return the ODataVersion of the specified edmx string. @@ -165,33 +166,32 @@ export async function getCdsAnnotations( } /** - * Determine if the specified connected system is hosted on BTP. - * If a backend system uses service keys, or a destination is an ABAP environment on BTP, then it is considered to be hosted on BTP. + * Determine if the specified connected system is ABAP cloud. * * @param connectedSystem - The connected system object. - * @returns {boolean} `true` if the connected system is hosted on BTP, otherwise `false`. + * @returns {boolean} `true` if the connected system is ABAP cloud, otherwise `false`. */ -export function isBTPHosted(connectedSystem?: ConnectedSystem): boolean { - return ( - !!connectedSystem?.backendSystem?.serviceKeys || - (connectedSystem?.destination ? isAbapEnvironmentOnBtp(connectedSystem.destination) : false) - ); +export function isAbapCloud(connectedSystem?: ConnectedSystem): boolean { + if (connectedSystem?.backendSystem) { + return getBackendSystemType(connectedSystem.backendSystem) === 'AbapCloud'; + } + return connectedSystem?.destination ? isAbapEnvironmentOnBtp(connectedSystem.destination) : false; } /** * Retrieves the data source label. * * @param {DatasourceType} source - The data source type (`DatasourceType.sapSystem` or `DatasourceType.businessHub`). - * @param scp + * @param abapCloud - Indicates if the SAP system is an ABAP Cloud system (BTP or S4HC). * @param {ApiHubType} apiHubType - The API hub type for business hubs. * @returns {string} The formatted data source label. */ -export function getReadMeDataSourceLabel(source: DatasourceType, scp = false, apiHubType?: ApiHubType): string { +export function getReadMeDataSourceLabel(source: DatasourceType, abapCloud = false, apiHubType?: ApiHubType): string { let dataSourceLabel: string | undefined; if (source === DatasourceType.sapSystem) { const labelDatasourceType = t(`readme.label.datasourceType.${DatasourceType.sapSystem}`); const labelSystemType = t( - `readme.label.sapSystemType.${scp ? SapSystemSourceType.SCP : SapSystemSourceType.ON_PREM}` + `readme.label.sapSystemType.${abapCloud ? SapSystemSourceType.ABAP_CLOUD : SapSystemSourceType.ON_PREM}` ); dataSourceLabel = `${labelDatasourceType} (${labelSystemType})`; } else if (source === DatasourceType.businessHub && apiHubType === ApiHubType.apiHubEnterprise) { diff --git a/packages/fiori-app-sub-generator/src/utils/telemetry.ts b/packages/fiori-app-sub-generator/src/utils/telemetry.ts index 7046d148bcf..a68643b5de9 100644 --- a/packages/fiori-app-sub-generator/src/utils/telemetry.ts +++ b/packages/fiori-app-sub-generator/src/utils/telemetry.ts @@ -1,6 +1,6 @@ import { isOnPremiseDestination } from '@sap-ux/btp-utils'; import { ApiHubType, type TelemetryBusinessHubType, type TelemetrySapSystemType } from '../types'; -import { isBTPHosted } from './common'; +import { isAbapCloud } from './common'; import type { ConnectedSystem } from '@sap-ux/odata-service-inquirer'; /** @@ -10,8 +10,8 @@ import type { ConnectedSystem } from '@sap-ux/odata-service-inquirer'; * @returns */ export function getTelemetrySapSystemType(connectedSystem: ConnectedSystem): TelemetrySapSystemType | undefined { - if (isBTPHosted(connectedSystem)) { - return 'SCP'; + if (isAbapCloud(connectedSystem)) { + return 'SCP'; // Legacy term, leaving as is to support telem conmtinuity } if ( @@ -20,7 +20,8 @@ export function getTelemetrySapSystemType(connectedSystem: ConnectedSystem): Tel ) { return 'ABAP'; } - // The only remaining case is CF on VSCode + // This wont ever be the case now as all reentrance ticket based connections are to Abap Cloud regardless of how the system was discovered + // This can probably be removed if (connectedSystem?.serviceProvider) { return 'CF'; } diff --git a/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/.appGenInfo.json b/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/.appGenInfo.json index 63c76cab411..99c4172db73 100644 --- a/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/.appGenInfo.json +++ b/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/.appGenInfo.json @@ -1,8 +1,8 @@ { "generationParameters": { - "generationDate": "Tue Jun 24 2025 22:45:01 GMT+0100 (Irish Standard Time)", + "generationDate": "Tue Sep 30 2025 16:47:22 GMT+0100 (Irish Standard Time)", "generatorPlatform": "CLI", - "serviceType": "SAP System (ABAP Environment on SAP Business Technology Platform)", + "serviceType": "SAP System (ABAP Cloud)", "metadataFilename": "", "serviceUrl": "https://abap.cloud.host/sap/opu/odata/sap/SEPMRA_PROD_MAN", "appName": "lrop_v2_sap_system", diff --git a/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/README.md b/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/README.md index e53f868813d..cdf1e7d57f1 100644 --- a/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/README.md +++ b/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/README.md @@ -1,12 +1,12 @@ ## Application Details | | | ------------- | -|**Generation Date and Time**
Tue Jun 24 2025 22:45:01 GMT+0100 (Irish Standard Time)| +|**Generation Date and Time**
Tue Sep 30 2025 16:47:22 GMT+0100 (Irish Standard Time)| |**App Generator**
SAP Fiori Application Generator| |**App Generator Version**
0.0.0| |**Generation Platform**
CLI| |**Template Used**
List Report Page V2| -|**Service Type**
SAP System (ABAP Environment on SAP Business Technology Platform)| +|**Service Type**
SAP System (ABAP Cloud)| |**Service URL**
https://abap.cloud.host/sap/opu/odata/sap/SEPMRA_PROD_MAN| |**Module Name**
lrop_v2_sap_system| |**Application Title**
Project's "Title"| diff --git a/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5-local.yaml b/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5-local.yaml index 6767f3d2d5a..0f62c5d082c 100644 --- a/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5-local.yaml +++ b/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5-local.yaml @@ -34,7 +34,7 @@ server: configuration: ignoreCertErrors: false # If set to true, certificate errors will be ignored. E.g. self-signed certificates will be accepted backend: - - scp: true + - authenticationType: reentranceTicket # SAML support for vscode path: /sap url: https://abap.cloud.host - name: sap-fe-mockserver diff --git a/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5-mock.yaml b/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5-mock.yaml index 5a4f5c38942..2e471b04785 100644 --- a/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5-mock.yaml +++ b/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5-mock.yaml @@ -16,7 +16,7 @@ server: - /test-resources url: https://ui5.sap.com backend: - - scp: true + - authenticationType: reentranceTicket # SAML support for vscode path: /sap url: https://abap.cloud.host - name: fiori-tools-appreload diff --git a/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5.yaml b/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5.yaml index 242e850f164..da57f7b1624 100644 --- a/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5.yaml +++ b/packages/fiori-app-sub-generator/test/int/fiori-elements/expected-output/lrop_v2_sap_system/ui5.yaml @@ -16,7 +16,7 @@ server: - /test-resources url: https://ui5.sap.com backend: - - scp: true + - authenticationType: reentranceTicket # SAML support for vscode path: /sap url: https://abap.cloud.host - name: fiori-tools-appreload diff --git a/packages/fiori-app-sub-generator/test/unit/fiori-app-generator/transforms.test.ts b/packages/fiori-app-sub-generator/test/unit/fiori-app-generator/transforms.test.ts index 5771284d032..80c5adbc40e 100644 --- a/packages/fiori-app-sub-generator/test/unit/fiori-app-generator/transforms.test.ts +++ b/packages/fiori-app-sub-generator/test/unit/fiori-app-generator/transforms.test.ts @@ -86,7 +86,7 @@ describe('Test transform state', () => { expect(ffApp.service?.previewSettings?.authenticationType).toEqual(AuthenticationType.ReentranceTicket); }); - test('should return preview setting `scp`', async () => { + test('should set preview setting `authenticationType` correctly', async () => { const state: State = { ...baseState, service: { @@ -104,8 +104,10 @@ describe('Test transform state', () => { (getHostEnvironment as jest.Mock).mockReturnValue(hostEnvironment.bas); let ffApp = await transformState>(state); - expect(ffApp.service?.previewSettings?.scp).toBe(true); + // All cloud systems support reentrance this supports BAS -> VSCode portability + expect(ffApp.service?.previewSettings?.authenticationType).toBe('reentranceTicket'); + // Should support legacy service key backend system entries, using reentrance tickets in new apps state.service!.connectedSystem = { backendSystem: { serviceKeys: { any: 'thing' }, @@ -116,7 +118,7 @@ describe('Test transform state', () => { } as Service['connectedSystem']; ffApp = await transformState>(state); - expect(ffApp.service?.previewSettings?.scp).toBe(true); + expect(ffApp.service?.previewSettings?.authenticationType).toBe('reentranceTicket'); }); test('should return preview setting `apiHub`', async () => { diff --git a/packages/fiori-app-sub-generator/test/unit/utils/common.test.ts b/packages/fiori-app-sub-generator/test/unit/utils/common.test.ts index 712f2cdf32e..4543c29e173 100644 --- a/packages/fiori-app-sub-generator/test/unit/utils/common.test.ts +++ b/packages/fiori-app-sub-generator/test/unit/utils/common.test.ts @@ -222,7 +222,9 @@ describe('Test utils', () => { let result = getReadMeDataSourceLabel(source, true); const labelDatasourceType = t(`readme.label.datasourceType.${source}`); - expect(result).toBe(`${labelDatasourceType} (${t(`readme.label.sapSystemType.${SapSystemSourceType.SCP}`)})`); + expect(result).toBe( + `${labelDatasourceType} (${t(`readme.label.sapSystemType.${SapSystemSourceType.ABAP_CLOUD}`)})` + ); result = getReadMeDataSourceLabel(source); expect(result).toBe( diff --git a/packages/fiori-generator-shared/package.json b/packages/fiori-generator-shared/package.json index 745775c6e33..133b86e0c53 100644 --- a/packages/fiori-generator-shared/package.json +++ b/packages/fiori-generator-shared/package.json @@ -46,7 +46,8 @@ "@types/vscode": "1.73.1", "@types/yeoman-environment": "2.10.11", "@sap-ux/axios-extension": "workspace:*", - "@sap-ux/logger": "workspace:*" + "@sap-ux/logger": "workspace:*", + "@sap-ux/store": "workspace:*" }, "engines": { "node": ">=20.x" diff --git a/packages/fiori-generator-shared/src/logging/logWrapper.ts b/packages/fiori-generator-shared/src/logging/logWrapper.ts index 50d94312912..3d1010e7e59 100644 --- a/packages/fiori-generator-shared/src/logging/logWrapper.ts +++ b/packages/fiori-generator-shared/src/logging/logWrapper.ts @@ -132,7 +132,7 @@ export class LogWrapper implements ILogWrapper, SapUxLogger { } LogWrapper._logLevel = logLevel === 'off' || !logLevel ? 'info' : logLevel; } - LogWrapper._vscodeLogger?.debug(t('debug.loggingConfigured', { logLevel: LogWrapper._logLevel })); + LogWrapper._vscodeLogger?.debug(t('logMessages.debug.loggingConfigured', { logLevel: LogWrapper._logLevel })); } static readonly logAtLevel = (level: LogLevel, message: string | object, ...args: any[]) => { @@ -156,7 +156,7 @@ export class LogWrapper implements ILogWrapper, SapUxLogger { ); } } else { - DefaultLogger.error(t('error.logWrapperNotInitialised')); + DefaultLogger.error(t('logMessages.error.logWrapperNotInitialised')); } }; diff --git a/packages/fiori-generator-shared/src/npm-package-scripts/getPackageScripts.ts b/packages/fiori-generator-shared/src/npm-package-scripts/getPackageScripts.ts index dedf59c686e..912c60d0c4b 100644 --- a/packages/fiori-generator-shared/src/npm-package-scripts/getPackageScripts.ts +++ b/packages/fiori-generator-shared/src/npm-package-scripts/getPackageScripts.ts @@ -18,7 +18,7 @@ function buildStartNoFLPCommand(localOnly: boolean, searchParams?: URLSearchPara const searchParamString = searchParams?.toString(); const searchParam = searchParamString ? `?${searchParamString}` : ''; if (localOnly) { - return `echo \\"${t('info.mockOnlyWarning')}\\"`; + return `echo \\"${t('logMessages.info.mockOnlyWarning')}\\"`; } return `fiori run --open "/index.html${searchParam}"`; } @@ -51,7 +51,7 @@ function buildParams(searchParams?: URLSearchParams, flpAppId?: string): string */ function buildStartCommand(localOnly: boolean, params: string, startFile?: string): string { if (localOnly) { - return `echo \\"${t('info.mockOnlyWarning')}\\"`; + return `echo \\"${t('logMessages.info.mockOnlyWarning')}\\"`; } return `fiori run --open "${startFile ?? SCRIPT_FLP_SANDBOX}${params}"`; } @@ -133,7 +133,7 @@ export function getPackageScripts({ } scripts['start-variants-management'] = localOnly - ? `echo \\"${t('info.mockOnlyWarning')}\\"` + ? `echo \\"${t('logMessages.info.mockOnlyWarning')}\\"` : getVariantPreviewAppScript(!supportVirtualEndpoints, flpAppId); return scripts; diff --git a/packages/fiori-generator-shared/src/system-utils.ts b/packages/fiori-generator-shared/src/system-utils.ts index 22b07220867..2780e1a3d96 100644 --- a/packages/fiori-generator-shared/src/system-utils.ts +++ b/packages/fiori-generator-shared/src/system-utils.ts @@ -1,31 +1,15 @@ -/** - * Relevant values for display extended system properties to the UI - */ -export enum Suffix { - S4HC = 'S4HC', - BTP = 'BTP' -} +import { t } from './i18n'; +import type { BackendSystem } from '@sap-ux/store'; /** - * Escape any special RegExp character that we want to use literally. + * Creates and returns a display name for the system, appending the system type and user display name if available. * - * @param str string input - * @returns string a cleansed version of the input + * @param system the backend system to create a display name for + * @returns the display name for the system */ -function escapeRegExp(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string -} - -/** - * Trim, cleanse and return a system name appended with the appropriate suffix i.e. BTP | S4HC. - * - * @param systemName name of the system - * @param suffix the appropriate suffix appended, BTP | S4HC - * @returns string return an escaped string, appended with the appropriate suffix - */ -function addSuffix(systemName: string, suffix: Suffix): string { - const suffixStr = ` (${suffix})`; - return RegExp(`${escapeRegExp(suffixStr)}$`).exec(systemName.trim()) ? systemName : `${systemName} (${suffix})`; +export function getBackendSystemDisplayName(system: BackendSystem): string { + const systemTypeName = getSystemDisplayName(system.name, system.userDisplayName, system.systemType); + return systemTypeName; } /** @@ -33,24 +17,26 @@ function addSuffix(systemName: string, suffix: Suffix): string { * * @param systemName - system name * @param displayUsername - display username - * @param isBtp - is BTP - * @param isS4HC - is S/4 Hana Cloud + * @param systemType - 'ABAPCloud' or undefined * @returns system display name */ -export function getSystemDisplayName( - systemName: string, - displayUsername?: string, - isBtp = false, - isS4HC = false -): string { +export function getSystemDisplayName(systemName: string, displayUsername?: string, systemType?: string): string { const userDisplayName = displayUsername ? ` [${displayUsername}]` : ''; - let systemDisplayName: string; - if (isBtp) { - systemDisplayName = addSuffix(systemName, Suffix.BTP); - } else if (isS4HC) { - systemDisplayName = addSuffix(systemName, Suffix.S4HC); - } else { - systemDisplayName = systemName; + return `${systemName}${getSystemTypeLabel(systemType)}${userDisplayName}`; +} + +/** + * Returns the formatted system type name for the given backend system. + * + * @param systemType the system type to get the parenthesised name for + * @returns system type name formatted as a string, e.g. " (ABAP Cloud)". + */ +function getSystemTypeLabel(systemType?: string): string { + let systemTypeName = ''; // for on prem we do not show the system type + const abapCloudLabel = ` (${t('texts.systemTypeLabel.abapCloud')})`; + // Legacy store system types will now display as ABAP Cloud + if (systemType === 'ABAPCloud' || systemType === 'S4HC' || systemType === 'BTP') { + systemTypeName = abapCloudLabel; } - return `${systemDisplayName}${userDisplayName}`; + return systemTypeName; } diff --git a/packages/fiori-generator-shared/src/translations/fiori-generator-shared.i18n.json b/packages/fiori-generator-shared/src/translations/fiori-generator-shared.i18n.json index bfd18c24f48..e4cf17a8db7 100644 --- a/packages/fiori-generator-shared/src/translations/fiori-generator-shared.i18n.json +++ b/packages/fiori-generator-shared/src/translations/fiori-generator-shared.i18n.json @@ -1,14 +1,21 @@ { - "info": { - "mockOnlyWarning": "This application was generated with a local metadata file and does not reference a live server. Please add the required server configuration or start this application with mock data using the target: `npm run start-mock`." - }, - "error": { - "logWrapperNotInitialised": "`LogWrapper` is not initialised." - }, - "debug": { - "loggingConfigured": "Logging has been configured at log level: {{logLevel}}." + "logMessages": { + "info": { + "mockOnlyWarning": "This application was generated with a local metadata file and does not reference a live server. Please add the required server configuration or start this application with mock data using the target: `npm run start-mock`." + }, + "error": { + "logWrapperNotInitialised": "`LogWrapper` is not initialised." + }, + "debug": { + "loggingConfigured": "Logging has been configured at log level: {{logLevel}}." + } }, "telemetry": { "unknownOs": "Unknown" + }, + "texts": { + "systemTypeLabel": { + "abapCloud": "ABAP Cloud" + } } } diff --git a/packages/fiori-generator-shared/test/system-utils.test.ts b/packages/fiori-generator-shared/test/system-utils.test.ts index 2d6dfe93137..5d19ec7299b 100644 --- a/packages/fiori-generator-shared/test/system-utils.test.ts +++ b/packages/fiori-generator-shared/test/system-utils.test.ts @@ -4,24 +4,28 @@ describe('Test SystemUtils', () => { describe('getSystemDisplayName', () => { test.each([ // Basic scenario - ['System1', undefined, false, false, 'System1'], - ['System1', 'User1', false, false, 'System1 [User1]'], + ['System1', undefined, undefined, 'System1'], + ['System1', 'User1', undefined, 'System1 [User1]'], - // BTP scenario - ['System2', undefined, true, false, 'System2 (BTP)'], - ['System2', 'User2', true, false, 'System2 (BTP) [User2]'], + // ABAP Cloud + ['System1', undefined, 'ABAPCloud', 'System1 (ABAP Cloud)'], + ['System1', 'User1', 'ABAPCloud', 'System1 (ABAP Cloud) [User1]'], - // S/4HANA Cloud scenario - ['System3', undefined, false, true, 'System3 (S4HC)'], - ['System3', 'User3', false, true, 'System3 (S4HC) [User3]'], + // Legacy stored BTP scenario + ['System2', undefined, 'BTP', 'System2 (ABAP Cloud)'], + ['System2', 'User2', 'BTP', 'System2 (ABAP Cloud) [User2]'], + + // Legacy stored S/4HANA Cloud scenario + ['System3', undefined, 'S4HC', 'System3 (ABAP Cloud)'], + ['System3', 'User3', 'S4HC', 'System3 (ABAP Cloud) [User3]'], // No suffix scenario - ['System5', undefined, false, false, 'System5'], - ['System5', 'User5', false, false, 'System5 [User5]'] + ['System5', undefined, undefined, 'System5'], + ['System5', 'User5', undefined, 'System5 [User5]'] ])( - 'returns correct display name for systemName: %s, displayUsername: %s, isBtp: %s, isS4HC: %s', - (systemName, displayUsername, isBtp, isS4HC, expected) => { - const result = getSystemDisplayName(systemName, displayUsername, isBtp, isS4HC); + 'returns correct display name for systemName: %s, displayUsername: %s, systemType: %s', + (systemName, displayUsername, systemType, expected) => { + const result = getSystemDisplayName(systemName, displayUsername, systemType); expect(result).toBe(expected); } ); diff --git a/packages/fiori-generator-shared/tsconfig.json b/packages/fiori-generator-shared/tsconfig.json index 83048393f74..4f2f7ced8ca 100644 --- a/packages/fiori-generator-shared/tsconfig.json +++ b/packages/fiori-generator-shared/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../project-access" }, + { + "path": "../store" + }, { "path": "../telemetry" } diff --git a/packages/odata-service-inquirer/src/prompts/connectionValidator.ts b/packages/odata-service-inquirer/src/prompts/connectionValidator.ts index e458c4f8ac8..b1e5c39e8bc 100644 --- a/packages/odata-service-inquirer/src/prompts/connectionValidator.ts +++ b/packages/odata-service-inquirer/src/prompts/connectionValidator.ts @@ -343,6 +343,9 @@ export class ConnectionValidator { if (isSystem) { await this.createSystemConnection({ axiosConfig, url, odataVersion }); + const systemInfo = await (this.serviceProvider as AbapServiceProvider).getSystemInfo(); + this._connectedUserName = systemInfo?.userName; + this._connectedSystemName = systemInfo?.systemID; } else { // Full service URL await this.createOdataServiceConnection(axiosConfig, url.pathname); @@ -383,7 +386,8 @@ export class ConnectionValidator { ignoreCertErrors: ignoreCertError, cookies: '', baseURL: url.origin, - url: url.pathname + url: url.pathname, + logger: LoggerHelper.logger }; if (username && password) { @@ -486,7 +490,6 @@ export class ConnectionValidator { * @param connectConfig.serviceInfo the service info * @param connectConfig.odataVersion the odata version to restrict the catalog requests if only a specific version is required * @param connectConfig.destination the destination to connect with - * @param connectConfig.refreshToken * @throws an error if the connection attempt fails, callers should handle the error */ private async createSystemConnection({ @@ -494,21 +497,19 @@ export class ConnectionValidator { url, serviceInfo, destination, - odataVersion, - refreshToken + odataVersion }: { axiosConfig?: AxiosExtensionRequestConfig & ProviderConfiguration; url?: URL; serviceInfo?: ServiceInfo; destination?: Destination; odataVersion?: ODataVersion; - refreshToken?: string; }): Promise { this.resetConnectionState(); this.resetValidity(); if (this.systemAuthType === 'reentranceTicket' || this.systemAuthType === 'serviceKey') { - this._serviceProvider = this.getAbapOnCloudServiceProvider(url, serviceInfo, refreshToken); + this._serviceProvider = this.getAbapOnCloudServiceProvider(url, serviceInfo); } else if (destination) { // Assumption: the destination configured URL is a valid URL, will be needed later for basic auth error handling this._validatedUrl = getDestinationUrlForAppStudio(destination.Name); @@ -610,7 +611,7 @@ export class ConnectionValidator { } /** - * Get the service provider for the Abap on Cloud environment. + * Get the service provider for the Abap Cloud environment. * * @param url the system url * @param serviceInfo the service info @@ -625,7 +626,8 @@ export class ConnectionValidator { if (this.systemAuthType === 'reentranceTicket' && url) { return createForAbapOnCloud({ environment: AbapCloudEnvironment.EmbeddedSteampunk, - url: new URL(url.pathname, url.origin).toString() + url: new URL(url.pathname, url.origin).toString(), + logger: LoggerHelper.logger }); } @@ -634,7 +636,8 @@ export class ConnectionValidator { environment: AbapCloudEnvironment.Standalone, service: serviceInfo, refreshToken, - refreshTokenChangedCb: this.refreshTokenChangedCb.bind(this) + refreshTokenChangedCb: this.refreshTokenChangedCb.bind(this), + logger: LoggerHelper.logger }); } @@ -648,14 +651,9 @@ export class ConnectionValidator { * * @param serviceInfo the service info containing the UAA details * @param odataVersion the odata version to restrict the catalog requests if only a specific version is required - * @param refreshToken the refresh token for the Abap on Cloud environment, will be used to avoid re-authentication while the token is valid * @returns true if the system is reachable and authenticated, if required, false if not, or an error message string */ - public async validateServiceInfo( - serviceInfo: ServiceInfo, - odataVersion?: ODataVersion, - refreshToken?: string - ): Promise { + public async validateServiceInfo(serviceInfo: ServiceInfo, odataVersion?: ODataVersion): Promise { if (!serviceInfo) { return false; } @@ -668,7 +666,7 @@ export class ConnectionValidator { } try { this.systemAuthType = 'serviceKey'; - await this.createSystemConnection({ serviceInfo, odataVersion, refreshToken }); + await this.createSystemConnection({ serviceInfo, odataVersion }); // Cache the user info this._connectedUserName = await (this.serviceProvider as AbapServiceProvider).user(); this._serviceInfo = serviceInfo; @@ -819,6 +817,9 @@ export class ConnectionValidator { this.validity.urlFormat = false; return false; } + if (systemAuthType) { + this.systemAuthType = systemAuthType; + } let url: URL; try { // Check if the url is valid @@ -826,13 +827,16 @@ export class ConnectionValidator { if (url.origin === 'null') { return t('errors.invalidUrl', { input: serviceUrl }); } + // Dont allow non origin URLs in for re-entrance tickets as the error handling would become complex to analyize. + // The connection may succeed but later we will get auth errors since axios-extension does not validate this. + // The new system name would also include the additional paths which would not make sense either. + if (this.systemAuthType === 'reentranceTicket' && !(url.pathname.length === 0 || url.pathname === '/')) { + return t('prompts.validationMessages.reentranceTicketSystemHostOnly'); + } } catch (error) { return t('errors.invalidUrl', { input: serviceUrl }); } - if (systemAuthType) { - this.systemAuthType = systemAuthType; - } try { if (!forceReValidation && this.isUrlValidated(serviceUrl)) { return this.validity.reachable ?? false; diff --git a/packages/odata-service-inquirer/src/prompts/datasources/sap-system/abap-on-btp/questions.ts b/packages/odata-service-inquirer/src/prompts/datasources/sap-system/abap-on-btp/questions.ts index 698b3aa4069..ca714a692f3 100644 --- a/packages/odata-service-inquirer/src/prompts/datasources/sap-system/abap-on-btp/questions.ts +++ b/packages/odata-service-inquirer/src/prompts/datasources/sap-system/abap-on-btp/questions.ts @@ -5,13 +5,7 @@ import { generateABAPCloudDestinationName } from '@sap-ux/btp-utils'; import { hostEnvironment } from '@sap-ux/fiori-generator-shared'; -import { - type FileBrowserQuestion, - type ListQuestion, - ERROR_TYPE, - getCFAbapInstanceChoices, - withCondition -} from '@sap-ux/inquirer-common'; +import { type ListQuestion, ERROR_TYPE, getCFAbapInstanceChoices, withCondition } from '@sap-ux/inquirer-common'; import type { OdataVersion } from '@sap-ux/odata-service-writer'; import { type ServiceInstanceInfo, apiGetInstanceCredentials } from '@sap/cf-tools'; import type { Answers, ListChoiceOptions, Question } from 'inquirer'; @@ -31,8 +25,6 @@ import { newSystemPromptNames } from '../new-system/types'; import { type ServiceAnswer, getSystemServiceQuestion } from '../service-selection'; import { getSystemUrlQuestion, getUserSystemNameQuestion } from '../shared-prompts/shared-prompts'; import { connectWithDestination } from '../system-selection/prompt-helpers'; -import { validateServiceKey } from '../validators'; -import { isFeatureEnabled } from '@sap-ux/feature-toggle'; const abapOnBtpPromptNamespace = 'abapOnBtp'; const systemUrlPromptName = `${abapOnBtpPromptNamespace}:${newSystemPromptNames.newSystemUrl}` as const; @@ -40,18 +32,14 @@ const cliCfAbapServicePromptName = 'cliCfAbapService'; const abapOnBtpPromptNames = { 'abapOnBtpAuthType': 'abapOnBtpAuthType', - 'serviceKey': 'serviceKey', 'cloudFoundryAbapSystem': 'cloudFoundryAbapSystem' } as const; -const SERVICE_KEY_FEATURE_TOGGLE = 'sap.ux.appGenerator.testBetaFeatures.disableBtpServiceKeyAuth'; - -export type AbapOnBTPType = 'cloudFoundry' | 'serviceKey' | 'reentranceTicket'; +export type AbapOnBTPType = 'cloudFoundry' | 'reentranceTicket'; interface AbapOnBtpAnswers extends Partial { [abapOnBtpPromptNames.abapOnBtpAuthType]?: AbapOnBTPType; [systemUrlPromptName]?: string; - [abapOnBtpPromptNames.serviceKey]?: string; [abapOnBtpPromptNames.cloudFoundryAbapSystem]?: ServiceInstanceInfo; } @@ -60,13 +48,11 @@ interface AbapOnBtpAnswers extends Partial { * * @param promptOptions The prompt options which control the service selection and system name] * @param cachedConnectedSystem if available passing an already connected system connection will prevent re-authentication for re-entrance ticket and service keys connection types - * @param serviceKeyToggle Feature toggle to enable/disable the BTP service key option - enabled by default * @returns The list of questions for the ABAP on BTP system */ export function getAbapOnBTPSystemQuestions( promptOptions?: OdataServicePromptOptions, - cachedConnectedSystem?: ConnectedSystem, - serviceKeyToggle = isFeatureEnabled(SERVICE_KEY_FEATURE_TOGGLE) + cachedConnectedSystem?: ConnectedSystem ): Question[] { PromptState.reset(); const connectValidator = new ConnectionValidator(); @@ -76,15 +62,6 @@ export function getAbapOnBTPSystemQuestions( name: abapOnBtpPromptNames.abapOnBtpAuthType, choices: [ { name: t('prompts.abapOnBTPType.choiceCloudFoundry'), value: 'cloudFoundry' as AbapOnBTPType }, - // Feature toggle the service key option - enabled by default, can be disabled via VS Code settings or ENV - ...(!serviceKeyToggle - ? [ - { - name: t('prompts.abapOnBTPType.choiceServiceKey'), - value: 'serviceKey' as AbapOnBTPType - } - ] - : []), { name: t('prompts.abapOnBTPType.choiceReentranceTicket'), value: 'reentranceTicket' as AbapOnBTPType } ], message: t('prompts.abapOnBTPType.message'), @@ -113,20 +90,7 @@ export function getAbapOnBTPSystemQuestions( } return false; } - )[0] - ); - - // Service Key file prompt - enabled by default - if (!serviceKeyToggle) { - questions.push( - withCondition( - [getServiceKeyPrompt(connectValidator, cachedConnectedSystem)], - (answers: AbapOnBtpAnswers) => answers?.abapOnBtpAuthType === 'serviceKey' - )[0] - ); - } - - questions.push( + )[0], ...withCondition( [...getCFDiscoverPrompts(connectValidator, undefined, undefined, cachedConnectedSystem)], (answers: AbapOnBtpAnswers) => answers?.abapOnBtpAuthType === 'cloudFoundry' @@ -203,7 +167,7 @@ async function validateCFServiceInfo( if ( cachedConnectedSystem && cachedConnectedSystem.backendSystem?.url === (uaaCreds.credentials as ServiceInfo).url && - JSON.stringify((cachedConnectedSystem.backendSystem.serviceKeys as ServiceInfo).uaa) === + JSON.stringify((cachedConnectedSystem.backendSystem.serviceKeys as ServiceInfo)?.uaa) === JSON.stringify((uaaCreds.credentials as ServiceInfo).uaa) ) { connectionValidator.setConnectedSystem(cachedConnectedSystem); @@ -313,53 +277,3 @@ export function getCFDiscoverPrompts( return questions; } - -/** - * Get the service key prompt for the ABAP on BTP system. This prompt will allow the user to select a service key file from the file system. - * - * @param connectionValidator a connection validator instance - * @param cachedConnectedSystem if available passing an already connected system connection will prevent re-authentication for re-entrance ticket and service keys connection types - * @returns The service key prompt - */ -function getServiceKeyPrompt( - connectionValidator: ConnectionValidator, - cachedConnectedSystem?: ConnectedSystem -): FileBrowserQuestion { - const question = { - type: 'input', - name: abapOnBtpPromptNames.serviceKey, - message: t('prompts.serviceKey.message'), - guiType: 'file-browser', - guiOptions: { - hint: t('prompts.serviceKey.hint'), - mandatory: true - }, - validate: async (keyPath) => { - PromptState.resetConnectedSystem(); - const serviceKeyValResult = validateServiceKey(keyPath); - if (typeof serviceKeyValResult === 'string' || typeof serviceKeyValResult === 'boolean') { - return serviceKeyValResult; - } - // Backend systems validation supports using a cached connections from a previous step execution to prevent re-authentication (e.g. re-opening a browser window) - // In case the user has changed the URL, do not use the cached connection. - if ( - cachedConnectedSystem && - cachedConnectedSystem.backendSystem?.url === serviceKeyValResult.url && - JSON.stringify((cachedConnectedSystem.backendSystem.serviceKeys as ServiceInfo).uaa) === - JSON.stringify(serviceKeyValResult.uaa) - ) { - connectionValidator.setConnectedSystem(cachedConnectedSystem); - } - const connectValResult = await connectionValidator.validateServiceInfo(serviceKeyValResult); - - if (connectValResult === true && connectionValidator.serviceProvider) { - PromptState.odataService.connectedSystem = { - serviceProvider: removeCircularFromServiceProvider(connectionValidator.serviceProvider) - }; - } - return connectValResult; - } - } as FileBrowserQuestion; - - return question; -} diff --git a/packages/odata-service-inquirer/src/prompts/datasources/sap-system/shared-prompts/shared-prompts.ts b/packages/odata-service-inquirer/src/prompts/datasources/sap-system/shared-prompts/shared-prompts.ts index d3ff3810379..3ce0eb99159 100644 --- a/packages/odata-service-inquirer/src/prompts/datasources/sap-system/shared-prompts/shared-prompts.ts +++ b/packages/odata-service-inquirer/src/prompts/datasources/sap-system/shared-prompts/shared-prompts.ts @@ -8,7 +8,12 @@ import type { Answers } from 'inquirer'; import { t } from '../../../../i18n'; import type { ConnectedSystem } from '../../../../types'; import { promptNames } from '../../../../types'; -import { PromptState, convertODataVersionType, removeCircularFromServiceProvider } from '../../../../utils'; +import { + PromptState, + convertODataVersionType, + isBackendSystemKeyExisting, + removeCircularFromServiceProvider +} from '../../../../utils'; import type { ConnectionValidator, SystemAuthType } from '../../../connectionValidator'; import { type NewSystemAnswers, newSystemPromptNames } from '../new-system/types'; import { suggestSystemName } from '../prompt-helpers'; @@ -26,8 +31,8 @@ function systemAuthTypeToAuthenticationType( systemAuthType: SystemAuthType | undefined ): AuthenticationType | undefined { switch (systemAuthType) { - case 'serviceKey': - return AuthenticationType.OAuth2RefreshToken; + case 'serviceKey' /** @deprecated All cloud auth is reentrance ticket based, legacy stored entries are still supported */: + return AuthenticationType.ReentranceTicket; case 'reentranceTicket': return AuthenticationType.ReentranceTicket; case 'basic': @@ -42,7 +47,7 @@ function systemAuthTypeToAuthenticationType( * @param connectValidator a connection validator instance used to validate the system url * @param promptNamespace The namespace for the prompt, used to identify the prompt instance and namespaced answers. * @param requiredOdataVersion The required OData version for the system connection, only catalogs supporting the specifc odata version will be used. - * @param cachedConnectedSystem + * @param cachedConnectedSystem An existing connection may be passed which will prevent reauthentication * @returns the system url prompt */ export function getSystemUrlQuestion( @@ -64,13 +69,21 @@ export function getSystemUrlQuestion( validate: async (url) => { PromptState.resetConnectedSystem(); // Backend systems validation supports using a cached connections from a previous step execution to prevent re-authentication (e.g. re-opening a browser window) - // Only in the case or re-entrance tickets will we reuse an existing connection. + // Only in the case of re-entrance tickets will we reuse an existing connection. if ( cachedConnectedSystem && cachedConnectedSystem.backendSystem?.url === url && cachedConnectedSystem.backendSystem?.authenticationType === 'reentranceTicket' ) { connectValidator.setConnectedSystem(cachedConnectedSystem); + } else { + const existingBackend = isBackendSystemKeyExisting(PromptState.backendSystemsCache, url); + if (existingBackend) { + // Not a cached connection so re-validate as new backend system entry + return t('prompts.validationMessages.backendSystemExistsWarning', { + backendName: existingBackend.name + }); + } } const valResult = await connectValidator.validateUrl(url, { isSystem: true, @@ -162,13 +175,12 @@ export function getUserSystemNameQuestion( client: connectValidator.validatedClient, username: connectValidator.axiosConfig?.auth?.username, password: connectValidator.axiosConfig?.auth?.password, - serviceKeys: connectValidator.serviceInfo, + serviceKeys: connectValidator.serviceInfo, // This will not be persisted and is only used to determine cached connection equality for CF provided uaa keys userDisplayName: connectValidator.connectedUserName, systemType: getBackendSystemType({ serviceKeys: connectValidator.serviceInfo, authenticationType: connectValidator.systemAuthType - } as BackendSystem), - refreshToken: connectValidator.refreshToken + } as BackendSystem) }); PromptState.odataService.connectedSystem.backendSystem = backendSystem; PromptState.odataService.connectedSystem.backendSystem.newOrUpdated = true; diff --git a/packages/odata-service-inquirer/src/prompts/datasources/sap-system/system-selection/prompt-helpers.ts b/packages/odata-service-inquirer/src/prompts/datasources/sap-system/system-selection/prompt-helpers.ts index 2e420f31c83..83431a77b80 100644 --- a/packages/odata-service-inquirer/src/prompts/datasources/sap-system/system-selection/prompt-helpers.ts +++ b/packages/odata-service-inquirer/src/prompts/datasources/sap-system/system-selection/prompt-helpers.ts @@ -18,6 +18,7 @@ import { convertODataVersionType, PromptState, removeCircularFromServiceProvider import type { ConnectionValidator } from '../../../connectionValidator'; import LoggerHelper from '../../../logger-helper'; import type { ValidationResult } from '../../../types'; +import { getBackendSystemDisplayName } from '@sap-ux/fiori-generator-shared'; // New system choice value is a hard to guess string to avoid conflicts with existing system names or user named systems // since it will be used as a new system value in the system selection prompt. @@ -68,8 +69,7 @@ export async function connectWithBackendSystem( } else if (backendSystem.serviceKeys) { connectValResult = await connectionValidator.validateServiceInfo( backendSystem.serviceKeys as ServiceInfo, - convertODataVersionType(requiredOdataVersion), - backendSystem.refreshToken + convertODataVersionType(requiredOdataVersion) ); } else if (backendSystem.authenticationType === 'basic' || !backendSystem.authenticationType) { let errorType; @@ -153,34 +153,6 @@ export async function connectWithDestination( return connectValResult; } -/** - * Creates and returns a display name for the system, appending the system type and user display name if available. - * - * @param system the backend system to create a display name for - * @returns the display name for the system - */ -export function getBackendSystemDisplayName(system: BackendSystem): string { - const userDisplayName = system.userDisplayName ? ` [${system.userDisplayName}]` : ''; - const systemTypeName = getBackendSystemTypeName(system.systemType); - return `${system.name}${systemTypeName}${userDisplayName}`; -} - -/** - * Returns the formatted system type name for the given backend system. - * - * @param systemType the system type to get the name for - * @returns system type name formatted as a string, e.g. " (BTP)" or " (S4HC)". - */ -function getBackendSystemTypeName(systemType?: string): string { - let systemTypeName = ''; // for on prem we do not show the system type - if (systemType === 'S4HC') { - systemTypeName = ` (${t('texts.systemTypeS4HC')})`; - } else if (systemType === 'BTP') { - systemTypeName = ` (${t('texts.systemTypeBTP')})`; - } - return systemTypeName; -} - /** * Matches the destination against the provided filters. Returns true if the destination matches any filters, false otherwise. * @@ -267,6 +239,9 @@ export async function createSystemChoices( } } else { const backendSystems = await new SystemService(LoggerHelper.logger).getAll({ includeSensitiveData: false }); + // Cache the backend systems + PromptState.backendSystemsCache = backendSystems; + systemChoices = backendSystems.map((system) => { return { name: getBackendSystemDisplayName(system), diff --git a/packages/odata-service-inquirer/src/translations/odata-service-inquirer.i18n.json b/packages/odata-service-inquirer/src/translations/odata-service-inquirer.i18n.json index a7a3f2d363d..99cdb87e2fc 100644 --- a/packages/odata-service-inquirer/src/translations/odata-service-inquirer.i18n.json +++ b/packages/odata-service-inquirer/src/translations/odata-service-inquirer.i18n.json @@ -58,7 +58,9 @@ "metadataFilePathNotValid": "The metadata file does not exist or is not accessible. Please specify a valid file path.", "capProjectNotFound": "The folder you have selected does not contain a valid CAP project. Please check and try again.", "warningCertificateValidationDisabled": "Certificate validation has been disabled by the user.", - "annotationsNotFound": "Annotations not found for the specified service." + "annotationsNotFound": "Annotations not found for the specified service.", + "backendSystemExistsWarning": "A saved system connection entry: \"{{- backendName}}\" already exists with the same URL and client. Please reuse the existing entry or remove it, using the systems panel, before adding a new connection.", + "reentranceTicketSystemHostOnly": "ABAP cloud system urls must only contain the hostname, for example, 'https://123.abc.com'" }, "warnings": { "nonUIServiceTypeWarningMessage": "Services of service type: {{serviceType}} or without a service type are not intended to be used for generating SAP Fiori UI applications.", @@ -252,8 +254,6 @@ "suggestedSystemNameClient": ", client {{client}}", "seeLogForDetails": "For more information, view the logs.", "forUserName": "(for user [{{username}}])", - "systemTypeBTP": "BTP", - "systemTypeS4HC": "S4HC", "httpStatus": "HTTP Status {{httpStatus}}", "checkDestinationAuthConfig": "Please check the SAP BTP destination authentication configuration.", "choiceNameNone": "None" diff --git a/packages/odata-service-inquirer/src/types.ts b/packages/odata-service-inquirer/src/types.ts index 28395dfb001..9de3d2a93ec 100644 --- a/packages/odata-service-inquirer/src/types.ts +++ b/packages/odata-service-inquirer/src/types.ts @@ -195,7 +195,7 @@ export interface EntitySelectionAnswers { * Answers related to the Page Building Block prompt. */ export interface PageBuildingBlockAnswers { - /** Indicates if the user wants to add a Page Building Block */ + /** Indicates if a Page Building Block should be addedn*/ [EntityPromptNames.addPageBuildingBlock]?: boolean; /** The title for the Page Building Block, required if addPageBuildingBlock is true */ [EntityPromptNames.pageBuildingBlockTitle]?: string; diff --git a/packages/odata-service-inquirer/src/utils/index.ts b/packages/odata-service-inquirer/src/utils/index.ts index 25a83fa5b11..f2457ca095e 100644 --- a/packages/odata-service-inquirer/src/utils/index.ts +++ b/packages/odata-service-inquirer/src/utils/index.ts @@ -12,6 +12,8 @@ import { convert } from '@sap-ux/annotation-converter'; import { parse } from '@sap-ux/edmx-parser'; import type { ConvertedMetadata } from '@sap-ux/vocabularies-types'; import { removeSync } from 'circular-reference-remover'; +import type { BackendSystem } from '@sap-ux/store'; +import { BackendSystemKey } from '@sap-ux/store'; /** * Determine if the current prompting environment is cli or a hosted extension (app studio or vscode). @@ -152,4 +154,21 @@ export function removeCircularFromServiceProvider(serviceProvider: ServiceProvid return serviceProvider; } +/** + * Checks if the specified backend systems contain a match for the specified url and client. + * + * @param backendSystems backend systems to search for a matching key + * @param url the url component of the backend system key + * @param client the client component of of the backend system key + * @returns the backend system if found or undefined + */ +export function isBackendSystemKeyExisting( + backendSystems: BackendSystem[], + url: string, + client?: string +): BackendSystem | undefined { + const newBackendSystemId = new BackendSystemKey({ url, client }).getId(); + return backendSystems.find((backendSystem) => BackendSystemKey.from(backendSystem).getId() === newBackendSystemId); +} + export { PromptState }; diff --git a/packages/odata-service-inquirer/src/utils/prompt-state.ts b/packages/odata-service-inquirer/src/utils/prompt-state.ts index be12842190f..d7ae7361606 100644 --- a/packages/odata-service-inquirer/src/utils/prompt-state.ts +++ b/packages/odata-service-inquirer/src/utils/prompt-state.ts @@ -1,3 +1,4 @@ +import type { BackendSystem } from '@sap-ux/store'; import type { OdataServiceAnswers } from '../types'; /** @@ -11,6 +12,11 @@ export class PromptState { public static isYUI = false; + /** + * To prevent re-reads from store load the backend systems once. + */ + public static backendSystemsCache: BackendSystem[] = []; + static reset(): void { // Reset all values in the odataService object, do not reset the object reference itself as it may be used by external consumers Object.keys(PromptState.odataService).forEach((key) => { diff --git a/packages/odata-service-inquirer/test/unit/__snapshots__/index-api.test.ts.snap b/packages/odata-service-inquirer/test/unit/__snapshots__/index-api.test.ts.snap index 2b8b9d13e6e..66292fc7ab7 100644 --- a/packages/odata-service-inquirer/test/unit/__snapshots__/index-api.test.ts.snap +++ b/packages/odata-service-inquirer/test/unit/__snapshots__/index-api.test.ts.snap @@ -51,7 +51,7 @@ exports[`API tests getPrompts, i18n is loaded 1`] = ` }, }, { - "name": "storedSystem2 (BTP)", + "name": "storedSystem2 (ABAP Cloud)", "value": { "system": { "name": "storedSystem2", @@ -217,10 +217,6 @@ exports[`API tests getPrompts, i18n is loaded 1`] = ` "name": "Discover a Cloud Foundry Service", "value": "cloudFoundry", }, - { - "name": "Upload a Service Key File", - "value": "serviceKey", - }, { "name": "Use Reentrance Ticket", "value": "reentranceTicket", @@ -245,18 +241,6 @@ exports[`API tests getPrompts, i18n is loaded 1`] = ` "validate": [Function], "when": [Function], }, - { - "guiOptions": { - "hint": "Select a local file that defines the service connection for an ABAP Environment on SAP Business Technology Platform.", - "mandatory": true, - }, - "guiType": "file-browser", - "message": "Service Key File Path", - "name": "serviceKey", - "type": "input", - "validate": [Function], - "when": [Function], - }, { "choices": [Function], "default": [Function], diff --git a/packages/odata-service-inquirer/test/unit/index-api.test.ts b/packages/odata-service-inquirer/test/unit/index-api.test.ts index e49e7b27af2..097c6ddfad0 100644 --- a/packages/odata-service-inquirer/test/unit/index-api.test.ts +++ b/packages/odata-service-inquirer/test/unit/index-api.test.ts @@ -1,11 +1,12 @@ +import { Severity } from '@sap-devx/yeoman-ui-types'; import { ErrorHandler } from '@sap-ux/inquirer-common'; -import { getPrompts, getSystemSelectionQuestions } from '../../src/index'; +import { type BackendSystem } from '@sap-ux/store'; +import type { OdataServicePromptOptions } from '../../src/index'; +import { getPrompts, getSystemSelectionQuestions, OdataVersion, promptNames } from '../../src/index'; import * as prompts from '../../src/prompts'; import * as systemSelection from '../../src/prompts/datasources/sap-system/system-selection'; import LoggerHelper from '../../src/prompts/logger-helper'; import { PromptState } from '../../src/utils'; -import { type BackendSystem } from '@sap-ux/store'; -import { isFeatureEnabled } from '@sap-ux/feature-toggle'; jest.mock('../../src/prompts', () => ({ __esModule: true, // Workaround for spyOn TypeError: Jest cannot redefine property @@ -17,10 +18,6 @@ jest.mock('../../src/prompts/datasources/sap-system/system-selection', () => ({ ...jest.requireActual('../../src/prompts/datasources/sap-system/system-selection') })); -jest.mock('@sap-ux/feature-toggle', () => ({ - isFeatureEnabled: jest.fn() -})); - jest.mock('@sap-ux/store', () => ({ __esModule: true, // Workaround for spyOn TypeError: Jest cannot redefine property ...jest.requireActual('@sap-ux/store'), @@ -43,7 +40,6 @@ jest.mock('@sap-ux/store', () => ({ describe('API tests', () => { beforeEach(() => { jest.restoreAllMocks(); - (isFeatureEnabled as jest.Mock).mockReturnValue(false); }); test('getPrompts', async () => { @@ -53,7 +49,23 @@ describe('API tests', () => { validate: () => (PromptState.odataService.metadata = 'metadata contents') } ]); - const { prompts: questions, answers } = await getPrompts(undefined, undefined, true, undefined, true); + + const prompOptions: OdataServicePromptOptions = { + [promptNames.serviceUrl]: { + requiredOdataVersion: OdataVersion.v4, + showCollaborativeDraftWarning: false, + additionalMessages: (input: any) => { + if (input === 'X') { + return { + message: 'X may mark the spot', + severity: Severity.information + }; + } + } + } + }; + + const { prompts: questions, answers } = await getPrompts(prompOptions); expect(questions).toHaveLength(1); // execute the validate function as it would be done by inquirer @@ -61,11 +73,12 @@ describe('API tests', () => { expect(answers.metadata).toBe('metadata contents'); // Ensure stateful properties are set correctly - expect(PromptState.isYUI).toBe(true); + expect(PromptState.isYUI).toBe(false); expect(PromptState.odataService).toBe(answers); + // Default logger created expect(LoggerHelper.logger).toBeDefined(); - expect(ErrorHandler.guidedAnswersEnabled).toBe(true); expect(ErrorHandler.logger).toBeDefined(); + expect(ErrorHandler.guidedAnswersEnabled).toBe(false); }); test('getSystemSelectionQuestions', async () => { diff --git a/packages/odata-service-inquirer/test/unit/prompts/__snapshots__/prompts.test.ts.snap b/packages/odata-service-inquirer/test/unit/prompts/__snapshots__/prompts.test.ts.snap index 4ce40a69ae2..39ec0be62fd 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/__snapshots__/prompts.test.ts.snap +++ b/packages/odata-service-inquirer/test/unit/prompts/__snapshots__/prompts.test.ts.snap @@ -51,7 +51,7 @@ exports[`getQuestions getQuestions 1`] = ` }, }, { - "name": "storedSystem2 (BTP)", + "name": "storedSystem2 (ABAP Cloud)", "value": { "system": { "name": "storedSystem2", @@ -229,10 +229,6 @@ exports[`getQuestions getQuestions 1`] = ` "name": "Discover a Cloud Foundry Service", "value": "cloudFoundry", }, - { - "name": "Upload a Service Key File", - "value": "serviceKey", - }, { "name": "Use Reentrance Ticket", "value": "reentranceTicket", @@ -257,18 +253,6 @@ exports[`getQuestions getQuestions 1`] = ` "validate": [Function], "when": [Function], }, - { - "guiOptions": { - "hint": "Select a local file that defines the service connection for an ABAP Environment on SAP Business Technology Platform.", - "mandatory": true, - }, - "guiType": "file-browser", - "message": "Service Key File Path", - "name": "serviceKey", - "type": "input", - "validate": [Function], - "when": [Function], - }, { "choices": [Function], "default": [Function], diff --git a/packages/odata-service-inquirer/test/unit/prompts/connectionValidator.test.ts b/packages/odata-service-inquirer/test/unit/prompts/connectionValidator.test.ts index 108264a0383..c2297c43284 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/connectionValidator.test.ts +++ b/packages/odata-service-inquirer/test/unit/prompts/connectionValidator.test.ts @@ -1,4 +1,5 @@ import type { AbapServiceProvider, ODataServiceInfo } from '@sap-ux/axios-extension'; +import { createForAbap } from '@sap-ux/axios-extension'; import * as axiosExtension from '@sap-ux/axios-extension'; import { ODataService, ODataVersion, ServiceProvider, type AxiosRequestConfig } from '@sap-ux/axios-extension'; import type { ServiceInfo } from '@sap-ux/btp-utils'; @@ -15,6 +16,7 @@ import { ConnectionValidator } from '../../../src/prompts/connectionValidator'; import LoggerHelper from '../../../src/prompts/logger-helper'; import type { ConnectedSystem } from '../../../src/types'; import * as nodejsUtils from '@sap-ux/nodejs-utils'; +import { ToolsLogger } from '@sap-ux/logger'; const odataServicesMock: ODataServiceInfo[] = []; const catalogServiceMock = jest.fn().mockImplementation(() => ({ @@ -27,18 +29,30 @@ jest.mock('@sap-ux/nodejs-utils', () => ({ ...jest.requireActual('@sap-ux/nodejs-utils') })); +const mockAbapServiceProvider = { + catalog: catalogServiceMock, + getSystemInfo: jest.fn().mockResolvedValue({ + systemID: 'ABC123', + userName: 'user1@acme.com', + userFullName: 'userFirstName1 userLastName1', + client: '000', + language: 'DE' + }), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + user: jest.fn().mockReturnValue('user1@acme.com') +}; + jest.mock('@sap-ux/axios-extension', () => ({ __esModule: true, ...jest.requireActual('@sap-ux/axios-extension'), - AbapServiceProvider: jest.fn().mockImplementation(() => ({ - catalog: catalogServiceMock - })), - createForAbapOnCloud: jest.fn().mockImplementation(({ refreshTokenChangedCb }) => ({ - interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, - catalog: catalogServiceMock, - user: jest.fn().mockReturnValue('user1@acme.com'), - refreshTokenChangedCb // Test only, usually handled by attachUaaAuthInterceptor but here for testing purposes - })) + AbapServiceProvider: jest.fn().mockImplementation(() => mockAbapServiceProvider), + createForAbapOnCloud: jest.fn().mockImplementation(() => mockAbapServiceProvider), + createForAbap: jest.fn().mockImplementation((...args: Parameters): AbapServiceProvider => { + const { createForAbap } = jest.requireActual('@sap-ux/axios-extension'); + const asp = createForAbap(args); + asp.getSystemInfo = mockAbapServiceProvider.getSystemInfo; + return asp; + }) })); let mockIsAppStudio = false; @@ -231,7 +245,7 @@ describe('ConnectionValidator', () => { ); }); - test('should report and any ignore cert errors with warning, when connecting to an odata service url, if `NODE_TLS_REJECT_UNAUTHORIZED=0` is set', async () => { + test('should report and ignore cert errors with warning, when connecting to an odata service url, if `NODE_TLS_REJECT_UNAUTHORIZED=0` is set', async () => { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; const serviceUrl = 'https://localhost:8080/some/path'; // Mock first request to get the specific cert errors @@ -407,7 +421,8 @@ describe('ConnectionValidator', () => { ignoreCertErrors: false, params: { 'sap-client': '999' - } + }, + logger: expect.any(ToolsLogger) }); expect(connectValidator.validity).toEqual({ authenticated: true, @@ -525,7 +540,6 @@ describe('ConnectionValidator', () => { expect(createAbapOnCloudProviderSpy).toHaveBeenCalledWith( expect.objectContaining({ environment: 'Standalone', - refreshTokenChangedCb: expect.any(Function), service: serviceInfoMock }) ); @@ -538,30 +552,20 @@ describe('ConnectionValidator', () => { expect(connectValidator.validatedUrl).toBe(serviceInfoMock.url); expect(connectValidator.connectedSystemName).toBe('abap_btp_001'); - // Ensure the refresh token is updated when it changes - (connectValidator.serviceProvider as any).refreshTokenChangedCb('newToken1234'); - expect(connectValidator.refreshToken).toEqual('newToken1234'); - connectValidator = new ConnectionValidator(); createAbapOnCloudProviderSpy.mockClear(); // Ensure refresh token is used to create a connection if presented - expect( - await connectValidator.validateServiceInfo(serviceInfoMock as ServiceInfo, undefined, '123refreshToken456') - ).toBe(true); + expect(await connectValidator.validateServiceInfo(serviceInfoMock as ServiceInfo)).toBe(true); expect(createAbapOnCloudProviderSpy).toHaveBeenCalledWith( expect.objectContaining({ environment: 'Standalone', - refreshTokenChangedCb: expect.any(Function), - service: serviceInfoMock, - refreshToken: '123refreshToken456' + service: serviceInfoMock }) ); createAbapOnCloudProviderSpy.mockClear(); // Should not create a new connection if the service url is the same as current valdidate url - expect( - await connectValidator.validateServiceInfo(serviceInfoMock as ServiceInfo, undefined, '123refreshToken456') - ).toBe(true); + expect(await connectValidator.validateServiceInfo(serviceInfoMock as ServiceInfo)).toBe(true); expect(createAbapOnCloudProviderSpy).not.toHaveBeenCalled(); }); @@ -625,7 +629,7 @@ describe('ConnectionValidator', () => { ); expect(warnLogSpy).toHaveBeenNthCalledWith(2, t('warnings.allowingUnauthorizedCertsNode')); - expect(createForAbapProviderSpy).toHaveBeenCalledWith( + expect(createForAbap as jest.Mock).toHaveBeenCalledWith( expect.objectContaining({ baseURL: 'https://example.com:1234', ignoreCertErrors: true @@ -927,9 +931,7 @@ describe('ConnectionValidator', () => { connectValidator.setConnectedSystem(cachedConnectedSystem); connectValResult = await connectValidator.validateServiceInfo( - cachedConnectedSystem.backendSystem!.serviceKeys as ServiceInfo, - undefined, - 'refreshToken1234' + cachedConnectedSystem.backendSystem!.serviceKeys as ServiceInfo ); expect(connectValResult).toEqual(true); diff --git a/packages/odata-service-inquirer/test/unit/prompts/prompts.test.ts b/packages/odata-service-inquirer/test/unit/prompts/prompts.test.ts index da08d1e8e9b..990fe19719c 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/prompts.test.ts +++ b/packages/odata-service-inquirer/test/unit/prompts/prompts.test.ts @@ -16,10 +16,6 @@ jest.mock('@sap-ux/btp-utils', () => { }; }); -jest.mock('@sap-ux/feature-toggle', () => ({ - isFeatureEnabled: jest.fn() -})); - jest.mock('@sap-ux/store', () => ({ __esModule: true, // Workaround to for spyOn TypeError: Jest cannot redefine property ...jest.requireActual('@sap-ux/store'), @@ -45,10 +41,6 @@ describe('getQuestions', () => { await initI18nOdataServiceInquirer(); }); - beforeEach(() => { - (isFeatureEnabled as jest.Mock).mockReturnValue(false); - }); - afterEach(() => { // Ensure test isolation jest.restoreAllMocks(); diff --git a/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-btp/questions.test.ts b/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-btp/questions.test.ts index 8ab924135c9..4e0079b53ff 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-btp/questions.test.ts +++ b/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-btp/questions.test.ts @@ -10,13 +10,8 @@ import { PromptState } from '../../../../../src/utils'; import * as sapSystemValidators from '../../../../../src/prompts/datasources/sap-system/validators'; import type { ConnectedSystem } from '../../../../../src/types'; import type { BackendSystem } from '@sap-ux/store'; -import { url } from 'inspector'; import { isFeatureEnabled } from '@sap-ux/feature-toggle'; -jest.mock('@sap-ux/feature-toggle', () => ({ - isFeatureEnabled: jest.fn() -})); - const validateUrlMock = jest.fn().mockResolvedValue(true); const validateAuthMock = jest.fn().mockResolvedValue(true); const isAuthRequiredMock = jest.fn().mockResolvedValue(false); @@ -73,8 +68,6 @@ describe('questions', () => { connectionValidatorMock.validateAuth = validateAuthMock; connectionValidatorMock.serviceProvider = serviceProviderMock; validateServiceInfoMock = true; - // Feature toggle disabled by default - service key shown - (isFeatureEnabled as jest.Mock).mockReturnValue(false); }); test('should return Abap on BTP questions', () => { @@ -87,10 +80,6 @@ describe('questions', () => { "name": "Discover a Cloud Foundry Service", "value": "cloudFoundry", }, - { - "name": "Upload a Service Key File", - "value": "serviceKey", - }, { "name": "Use Reentrance Ticket", "value": "reentranceTicket", @@ -114,18 +103,6 @@ describe('questions', () => { "validate": [Function], "when": [Function], }, - { - "guiOptions": { - "hint": "Select a local file that defines the service connection for an ABAP Environment on SAP Business Technology Platform.", - "mandatory": true, - }, - "guiType": "file-browser", - "message": "Service Key File Path", - "name": "serviceKey", - "type": "input", - "validate": [Function], - "when": [Function], - }, { "choices": [Function], "default": [Function], @@ -180,63 +157,22 @@ describe('questions', () => { ] `); }); - test.each([ - { - description: 'should show the service key question when feature toggle is disabled (default)', - featureEnabled: false, - expectServiceKeyChoice: true, - expectServiceKeyPrompt: true - }, - { - description: 'should hide the service key question when feature toggle is enabled', - featureEnabled: true, - expectServiceKeyChoice: false, - expectServiceKeyPrompt: false - } - ])('$description', ({ featureEnabled, expectServiceKeyChoice, expectServiceKeyPrompt }) => { - (isFeatureEnabled as jest.Mock).mockReturnValue(featureEnabled); - - const questions = getAbapOnBTPSystemQuestions(); - const authTypePrompt = questions.find((q) => q.name === 'abapOnBtpAuthType') as ListQuestion; - - if (expectServiceKeyChoice) { - expect(authTypePrompt.choices).toContainEqual({ - name: 'Upload a Service Key File', - value: 'serviceKey' - }); - } else { - expect(authTypePrompt.choices).not.toContainEqual({ - name: 'Upload a Service Key File', - value: 'serviceKey' - }); - } - - expect(questions.some((q) => q.name === 'serviceKey')).toBe(expectServiceKeyPrompt); - }); test('should show the correct auth type prompt', () => { const newSystemQuestions = getAbapOnBTPSystemQuestions(); const authTypePrompt = newSystemQuestions.find((q) => q.name === 'abapOnBtpAuthType') as ListQuestion; expect(authTypePrompt.choices).toEqual([ { name: 'Discover a Cloud Foundry Service', value: 'cloudFoundry' }, - { name: 'Upload a Service Key File', value: 'serviceKey' }, { name: 'Use Reentrance Ticket', value: 'reentranceTicket' } ]); - // 'cloudFoundry' | 'serviceKey' | 'reentranceTicket'; + // 'cloudFoundry' | 'reentranceTicket'; const reentranceTicketUrlPrompt = newSystemQuestions.find((q) => q.name === 'abapOnBtp:newSystemUrl'); expect((reentranceTicketUrlPrompt?.when as Function)({ 'abapOnBtpAuthType': 'reentranceTicket' })).toBe(true); - expect((reentranceTicketUrlPrompt?.when as Function)({ 'abapOnBtpAuthType': 'serviceKey' })).toBe(false); expect((reentranceTicketUrlPrompt?.when as Function)({ 'abapOnBtpAuthType': 'cloudFoundry' })).toBe(false); - const serviceKeyPrompt = newSystemQuestions.find((q) => q.name === 'serviceKey'); - expect((serviceKeyPrompt?.when as Function)({ 'abapOnBtpAuthType': 'reentranceTicket' })).toBe(false); - expect((serviceKeyPrompt?.when as Function)({ 'abapOnBtpAuthType': 'serviceKey' })).toBe(true); - expect((serviceKeyPrompt?.when as Function)({ 'abapOnBtpAuthType': 'cloudFoundry' })).toBe(false); - const cfAbapSysPrompt = newSystemQuestions.find((q) => q.name === 'cloudFoundryAbapSystem'); expect((cfAbapSysPrompt?.when as Function)({ 'abapOnBtpAuthType': 'reentranceTicket' })).toBe(false); - expect((cfAbapSysPrompt?.when as Function)({ 'abapOnBtpAuthType': 'serviceKey' })).toBe(false); expect((cfAbapSysPrompt?.when as Function)({ 'abapOnBtpAuthType': 'cloudFoundry' })).toBe(true); }); @@ -420,67 +356,6 @@ describe('questions', () => { expect(PromptState.odataService.connectedSystem).toBeUndefined(); }); - test('Service key prompt should validate service key and connect', async () => { - const serviceInfoMock: ServiceInfo = { - uaa: { - clientid: 'clientid1', - clientsecret: 'clientSecret1', - url: 'http://abap.on.btp:1234' - }, - url: 'http://abap.on.btp:1234', - catalogs: { - abap: { - path: 'path1', - type: 'type1' - } - } - }; - let validateServiceKeyFileMock = jest - .spyOn(sapSystemValidators, 'validateServiceKey') - .mockReturnValue(serviceInfoMock); // service key file is valid - validateServiceInfoMock = true; // connection is successful - let newSystemQuestions = getAbapOnBTPSystemQuestions(); - - let serviceKeyPrompt = newSystemQuestions.find((q) => q.name === 'serviceKey'); - expect(await (serviceKeyPrompt?.validate as Function)('path/to/service/key')).toBe(true); - expect(validateServiceKeyFileMock).toHaveBeenCalledWith('path/to/service/key'); - expect(PromptState.odataService).toEqual({ connectedSystem: { serviceProvider: serviceProviderMock } }); - - validateServiceKeyFileMock = jest - .spyOn(sapSystemValidators, 'validateServiceKey') - .mockReturnValue('invalid service key file'); // service key file is valid - expect(await (serviceKeyPrompt?.validate as Function)('path/to/service/key')).toBe('invalid service key file'); - - // Should connect using a cached connected system when provided - const backendSystemServiceKeys: BackendSystem = { - name: 'http://abap.on.btp:1234', - url: 'http://abap.on.btp:1234', - authenticationType: 'serviceKeys', - serviceKeys: { - uaa: serviceInfoMock.uaa, - url: serviceInfoMock.url, - systemid: 'abap_btp_001' - } - }; - const cachedConnectedSystem: ConnectedSystem = { - serviceProvider: { - catalog: {} - } as unknown as AbapServiceProvider, - backendSystem: backendSystemServiceKeys - }; - - validateServiceKeyFileMock = jest - .spyOn(sapSystemValidators, 'validateServiceKey') - .mockReturnValue(serviceInfoMock); // service key file is valid - newSystemQuestions = getAbapOnBTPSystemQuestions(undefined, cachedConnectedSystem); - serviceKeyPrompt = newSystemQuestions.find((q) => q.name === 'serviceKey'); - PromptState.reset(); - validateServiceInfoMock = true; - expect(await ((serviceKeyPrompt as ListQuestion).validate as Function)('path/to/service/key')).toBe(true); - expect(PromptState.odataService.connectedSystem?.serviceProvider).toBeDefined(); // Should be set from cached connected system - expect(connectionValidatorMock.setConnectedSystem).toHaveBeenCalledWith(cachedConnectedSystem); - }); - test('Reentrance ticket (system url) prompt should use cached connected system if provided', async () => { const backendSystemReentrance: BackendSystem = { name: 'http://s4hc:1234', diff --git a/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-prem/questions.test.ts b/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-prem/questions.test.ts index 8506371e2cc..e403cf6b3d9 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-prem/questions.test.ts +++ b/packages/odata-service-inquirer/test/unit/prompts/sap-system/abap-on-prem/questions.test.ts @@ -207,6 +207,24 @@ describe('questions', () => { expect(await (userSystemNamePrompt?.when as Function)({ [systemUrlPromptName]: systemUrl })).toBe(true); }); + test('Should check if an existing backend system configuration exists and show validation error (VSCode)', async () => { + const systemUrl = 'http://some.abap.system:1234'; + const systemUrlPromptName = `abapOnPrem:${newSystemPromptNames.newSystemUrl}`; + const newSystemQuestions = getAbapOnPremQuestions(); + const systemUrlQuestion = newSystemQuestions.find((question) => question.name === systemUrlPromptName); + PromptState.backendSystemsCache = [ + { + name: 'System1234', + url: systemUrl, + systemType: 'OnPrem' + } + ]; + + expect(await (systemUrlQuestion?.validate as Function)(systemUrl)).toEqual( + t('prompts.validationMessages.backendSystemExistsWarning', { backendName: 'System1234' }) + ); + }); + test('Should validate sap-client input', () => { const newSystemQuestions = getAbapOnPremQuestions(); const sapClientPrompt = newSystemQuestions.find((question) => question.name === `sapClient`); diff --git a/packages/odata-service-inquirer/test/unit/prompts/sap-system/questions.test.ts b/packages/odata-service-inquirer/test/unit/prompts/sap-system/questions.test.ts index 30e506f0a3e..5ae8d29e527 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/sap-system/questions.test.ts +++ b/packages/odata-service-inquirer/test/unit/prompts/sap-system/questions.test.ts @@ -6,20 +6,12 @@ import type { BackendSystem } from '@sap-ux/store'; import * as abapOnBtpQuestions from '../../../../src/prompts/datasources/sap-system/abap-on-btp/questions'; import { isFeatureEnabled } from '@sap-ux/feature-toggle'; -jest.mock('@sap-ux/feature-toggle', () => ({ - isFeatureEnabled: jest.fn() -})); - describe('questions', () => { beforeAll(async () => { // Wait for i18n to bootstrap so we can test localised strings await initI18nOdataServiceInquirer(); }); - beforeEach(() => { - (isFeatureEnabled as jest.Mock).mockReturnValue(false); - }); - test('should return expected questions', () => { const newSystemQuestions = getNewSystemQuestions(); expect(newSystemQuestions).toMatchInlineSnapshot(` @@ -128,10 +120,6 @@ describe('questions', () => { "name": "Discover a Cloud Foundry Service", "value": "cloudFoundry", }, - { - "name": "Upload a Service Key File", - "value": "serviceKey", - }, { "name": "Use Reentrance Ticket", "value": "reentranceTicket", @@ -156,18 +144,6 @@ describe('questions', () => { "validate": [Function], "when": [Function], }, - { - "guiOptions": { - "hint": "Select a local file that defines the service connection for an ABAP Environment on SAP Business Technology Platform.", - "mandatory": true, - }, - "guiType": "file-browser", - "message": "Service Key File Path", - "name": "serviceKey", - "type": "input", - "validate": [Function], - "when": [Function], - }, { "choices": [Function], "default": [Function], diff --git a/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/__snapshots__/questions.test.ts.snap b/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/__snapshots__/questions.test.ts.snap index 3447eae4430..f1995904c34 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/__snapshots__/questions.test.ts.snap +++ b/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/__snapshots__/questions.test.ts.snap @@ -295,10 +295,6 @@ exports[`Test system selection prompts should return system selection prompts an "name": "Discover a Cloud Foundry Service", "value": "cloudFoundry", }, - { - "name": "Upload a Service Key File", - "value": "serviceKey", - }, { "name": "Use Reentrance Ticket", "value": "reentranceTicket", @@ -323,18 +319,6 @@ exports[`Test system selection prompts should return system selection prompts an "validate": [Function], "when": [Function], }, - { - "guiOptions": { - "hint": "Select a local file that defines the service connection for an ABAP Environment on SAP Business Technology Platform.", - "mandatory": true, - }, - "guiType": "file-browser", - "message": "Service Key File Path", - "name": "serviceKey", - "type": "input", - "validate": [Function], - "when": [Function], - }, { "choices": [Function], "default": [Function], diff --git a/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/prompt-helpers.test.ts b/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/prompt-helpers.test.ts index 99e13b98b0a..bba21be3745 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/prompt-helpers.test.ts +++ b/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/prompt-helpers.test.ts @@ -4,7 +4,6 @@ import { CfAbapEnvServiceChoice, createSystemChoices, findDefaultSystemSelectionIndex, - getBackendSystemDisplayName, NewSystemChoice } from '../../../../../src/prompts/datasources/sap-system/system-selection/prompt-helpers'; import type { AuthenticationType, BackendSystem } from '@sap-ux/store'; @@ -70,26 +69,6 @@ describe('Test system selection prompt helpers', () => { mockIsAppStudio = false; }); - test('Should get backend system display name', () => { - expect( - getBackendSystemDisplayName({ - name: 'systemA', - userDisplayName: 'userDisplayName1', - authenticationType: 'reentranceTicket' as AuthenticationType, - systemType: 'S4HC' - } as BackendSystem) - ).toEqual('systemA (S4HC) [userDisplayName1]'); - - expect( - getBackendSystemDisplayName({ - name: 'systemB', - userDisplayName: 'userDisplayName2', - serviceKeys: { url: 'Im a service key' }, - systemType: 'BTP' - } as BackendSystem) - ).toEqual('systemB (BTP) [userDisplayName2]'); - }); - test('Should create backend system selection choices', async () => { expect(await createSystemChoices()).toEqual([ { @@ -107,7 +86,7 @@ describe('Test system selection prompt helpers', () => { } }, { - name: `${backendSystemReentrance.name} (S4HC)`, + name: `${backendSystemReentrance.name} (ABAP Cloud)`, value: { system: backendSystemReentrance, type: 'backendSystem' diff --git a/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/questions.test.ts b/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/questions.test.ts index f8451d70548..aa58b2fc5ea 100644 --- a/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/questions.test.ts +++ b/packages/odata-service-inquirer/test/unit/prompts/sap-system/system-selection/questions.test.ts @@ -30,10 +30,6 @@ jest.mock('../../../../../src/utils', () => ({ getPromptHostEnvironment: jest.fn() })); -jest.mock('@sap-ux/feature-toggle', () => ({ - isFeatureEnabled: jest.fn() -})); - const backendSystemBasic: BackendSystem = { name: 'http://abap.on.prem:1234', url: 'http://abap.on.prem:1234', @@ -172,7 +168,6 @@ describe('Test system selection prompts', () => { isAuthRequiredMock.mockResolvedValue(false); validateServiceInfoResultMock = true; validateUrlResultMock = true; - (isFeatureEnabled as jest.Mock).mockReturnValue(false); }); test('should return system selection prompts and choices based on development environment, BAS or non-BAS', async () => { @@ -586,12 +581,12 @@ describe('Test system selection prompts', () => { ); }); - test('getSystemConnectionQuestions: non-BAS (BackendSystem, AuthType: serviceKeys, RefreshToken)', async () => { + test('getSystemConnectionQuestions: non-BAS (BackendSystem, AuthType: serviceKeys)', async () => { mockIsAppStudio = false; const connectValidator = new ConnectionValidator(); (getPromptHostEnvironment as jest.Mock).mockReturnValue(hostEnvironment.cli); const validateServiceInfoSpy = jest.spyOn(connectValidator, 'validateServiceInfo'); - const backendSystemServiceKeysClone = { ...backendSystemServiceKeys, refreshToken: '123refreshToken456' }; + const backendSystemServiceKeysClone = { ...backendSystemServiceKeys }; backendSystems.push(backendSystemServiceKeysClone); systemServiceMock.read = jest.fn().mockResolvedValue(backendSystemServiceKeysClone); @@ -603,11 +598,7 @@ describe('Test system selection prompts', () => { system: backendSystemServiceKeysClone } as SystemSelectionAnswerType) ).toBe(true); - expect(validateServiceInfoSpy).toHaveBeenCalledWith( - backendSystemServiceKeysClone.serviceKeys, - undefined, - backendSystemServiceKeysClone.refreshToken - ); + expect(validateServiceInfoSpy).toHaveBeenCalledWith(backendSystemServiceKeysClone.serviceKeys, undefined); }); test('should execute additional prompt on CLI (if autocomplete is not used) to handle YUI validate function', async () => { diff --git a/packages/store/src/entities/backend-system.ts b/packages/store/src/entities/backend-system.ts index 90c765921de..8c6a0bca202 100644 --- a/packages/store/src/entities/backend-system.ts +++ b/packages/store/src/entities/backend-system.ts @@ -16,7 +16,7 @@ export class BackendSystem { @serializable public readonly client?: string; @serializable public readonly userDisplayName?: string; @serializable public readonly systemType?: string; - @sensitiveData public readonly serviceKeys?: unknown; + public readonly serviceKeys?: unknown; /** No longer persisted but needed for backward compatibility reads */ @sensitiveData public readonly refreshToken?: string; @sensitiveData public readonly username?: string; @sensitiveData public readonly password?: string; @@ -61,7 +61,7 @@ export class BackendSystemKey implements EntityKey { private url: string; private client?: string; - public static from(system: BackendSystem): EntityKey { + public static from(system: BackendSystem): BackendSystemKey { return new BackendSystemKey({ url: system.url, client: system.client }); } diff --git a/packages/store/src/services/backend-system.ts b/packages/store/src/services/backend-system.ts index cfbf4dfe39f..b741f98370d 100644 --- a/packages/store/src/services/backend-system.ts +++ b/packages/store/src/services/backend-system.ts @@ -2,8 +2,7 @@ import type { Logger } from '@sap-ux/logger'; import type { Service, ServiceRetrievalOptions } from '.'; import type { DataProvider } from '../data-provider'; import { SystemDataProvider } from '../data-provider/backend-system'; -import type { BackendSystemKey } from '../entities/backend-system'; -import { BackendSystem } from '../entities/backend-system'; +import { BackendSystem, BackendSystemKey } from '../entities/backend-system'; import { text } from '../i18n'; import type { ServiceOptions } from '../types'; @@ -22,7 +21,9 @@ export class SystemService implements Service { this.validatePartialUpdateInput(entity); const existingSystem = await this.readOrThrow(key); const updatedEntity = this.mergeProperties(entity, existingSystem); - return this.write(updatedEntity); + return this.write(updatedEntity, { + force: true + }); } private mergeProperties(update: Partial, existingSystem: BackendSystem): BackendSystem { @@ -51,7 +52,24 @@ export class SystemService implements Service { public async read(key: BackendSystemKey): Promise { return this.dataProvider.read(key); } - public async write(entity: BackendSystem): Promise { + + /** + * Write the backend system to the store. If a backend entity with the same key already exists and error is thrown. + * Use the `force` option to overwrite, use with cautions and are sure other clients will not break. + * + * @param entity the backend system to write + * @param options + * @param options.force Force overwrite existing backend system with the same key + * @returns + */ + public async write(entity: BackendSystem, options?: { force: boolean }): Promise { + // Prevent overwrite of existing entity with the same key unless explicitly forced + const entityKey = BackendSystemKey.from(entity); + const existingSystem = await this.read(BackendSystemKey.from(entity)); + + if (!options?.force && existingSystem) { + throw new Error(text('error.backendSystemEntityKeyExists', { entityKey: entityKey.getId() })); + } return this.dataProvider.write(entity); } public async delete(entity: BackendSystem): Promise { diff --git a/packages/store/src/services/index.ts b/packages/store/src/services/index.ts index 0ca5faff9a8..5c92742d2e3 100644 --- a/packages/store/src/services/index.ts +++ b/packages/store/src/services/index.ts @@ -8,7 +8,7 @@ export interface ServiceRetrievalOptions { */ export interface Service { read(key: EntityKey): Promise; - write(entity: Entity): Promise; + write(entity: Entity, options?: unknown): Promise; partialUpdate(key: EntityKey, entity: Partial): Promise; delete(entity: Entity): Promise; getAll(options?: ServiceRetrievalOptions): Promise; diff --git a/packages/store/src/translations/ux-store.i18n.json b/packages/store/src/translations/ux-store.i18n.json index 5a0782a0759..4f3e6a1cafd 100644 --- a/packages/store/src/translations/ux-store.i18n.json +++ b/packages/store/src/translations/ux-store.i18n.json @@ -6,7 +6,8 @@ "noPropertiesSpecified": "No properties specified for update", "systemMigrationFailed": "Migrating systems from secure store failed", "systemAlreadyExistsInHybridStore": "System with ID: [{{systemId}}] already exists in hybrid store. Not migrating", - "couldNotDeleteRefreshToken": "Could not delete refresh token for [{{- url}}]" + "couldNotDeleteRefreshToken": "Could not delete refresh token for [{{- url}}]", + "backendSystemEntityKeyExists": "The 'BackendSystem' entity was not written because a 'BackendSystem' entity key already exists: {{entityKey}}." }, "info": { "foundRefreshToken": "Found refresh token for {{- systemId}}", diff --git a/packages/store/src/utils/backend.ts b/packages/store/src/utils/backend.ts index 239e9a30028..1962dc84449 100644 --- a/packages/store/src/utils/backend.ts +++ b/packages/store/src/utils/backend.ts @@ -1,6 +1,6 @@ import type { BackendSystem } from '../entities/backend-system'; -export type SystemType = 'OnPrem' | 'S4HC' | 'BTP' | undefined; +export type SystemType = 'OnPrem' | 'AbapCloud' | undefined; /** * Determines the backend system type based on the authentication type and service keys (defaults to OnPrem). @@ -11,9 +11,10 @@ export type SystemType = 'OnPrem' | 'S4HC' | 'BTP' | undefined; export function getBackendSystemType(system: BackendSystem): SystemType { let backendSystemType: SystemType; if (system.authenticationType === 'reentranceTicket') { - backendSystemType = 'S4HC'; + backendSystemType = 'AbapCloud'; } else if (system.serviceKeys) { - backendSystemType = 'BTP'; + /** @deprecated Basing the system type on the auth method is no longer supported since service key support removal */ + backendSystemType = 'AbapCloud'; } else if (system.authenticationType === 'basic' || system.username) { backendSystemType = 'OnPrem'; } diff --git a/packages/store/test/unit/data-provider/backend-system.test.ts b/packages/store/test/unit/data-provider/backend-system.test.ts index af8558e3802..342b31040a0 100644 --- a/packages/store/test/unit/data-provider/backend-system.test.ts +++ b/packages/store/test/unit/data-provider/backend-system.test.ts @@ -197,12 +197,12 @@ describe('Backend system data provider', () => { expect(mockHybridStore.partialUpdate).toHaveBeenNthCalledWith(1, { entityName: Entities.BackendSystem, id: 'sys1', - entity: { systemType: 'S4HC' } + entity: { systemType: 'AbapCloud' } }); expect(mockHybridStore.partialUpdate).toHaveBeenNthCalledWith(2, { entityName: Entities.BackendSystem, id: 'sys2', - entity: { systemType: 'BTP' } + entity: { systemType: 'AbapCloud' } }); expect(mockHybridStore.partialUpdate).toHaveBeenNthCalledWith(3, { entityName: Entities.BackendSystem, diff --git a/packages/system-access/src/base/connect.ts b/packages/system-access/src/base/connect.ts index f6413025727..55d2dea1025 100644 --- a/packages/system-access/src/base/connect.ts +++ b/packages/system-access/src/base/connect.ts @@ -200,7 +200,7 @@ export async function createAbapServiceProvider( if (isAppStudio() && isDestinationTarget(target)) { provider = await createAbapDestinationServiceProvider(options, target, prompt); } else if (isUrlTarget(target)) { - if (target.scp) { + if (target.scp || target.serviceKey) { provider = await createAbapCloudServiceProvider(options, target, prompt, logger); } else if (target.authenticationType === AuthenticationType.ReentranceTicket) { provider = createForAbapOnCloud({ diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 5a5aa8bd69b..b0fe11299c6 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -41,8 +41,6 @@ "devDependencies": { "jest-extended": "6.0.0", "memfs": "3.4.13", - "ts-jest": "29.4.1", - "ts-node": "10.9.2", "unionfs": "4.4.0", "dotenv": "16.3.1" }, diff --git a/packages/telemetry/test/index.test.ts b/packages/telemetry/test/index.test.ts index 56a56dd1ecf..94853e74c3e 100644 --- a/packages/telemetry/test/index.test.ts +++ b/packages/telemetry/test/index.test.ts @@ -1,5 +1,3 @@ -jest.disableAutomock(); - import { EventHeader } from '../src/base/types/event-header'; describe('Telemetry API Tests', () => { diff --git a/packages/telemetry/test/performance/index.test.ts b/packages/telemetry/test/performance/index.test.ts index 723a16ce521..cf082f2e55f 100644 --- a/packages/telemetry/test/performance/index.test.ts +++ b/packages/telemetry/test/performance/index.test.ts @@ -1,5 +1,3 @@ -jest.disableAutomock(); - import { PerformanceMeasurementAPI } from '../../src/base/performance/api'; import type { Measurement as IMeasurement, PerformanceMeasurement } from '../../src/base/performance/types'; import { EntryType } from '../../src/base/performance/types'; diff --git a/packages/telemetry/test/util/paramProcessing.test.ts b/packages/telemetry/test/util/paramProcessing.test.ts index 699b928ee9f..2e8423c5f7e 100644 --- a/packages/telemetry/test/util/paramProcessing.test.ts +++ b/packages/telemetry/test/util/paramProcessing.test.ts @@ -1,5 +1,3 @@ -jest.disableAutomock(); - import type { dimensions, measurements } from '../../src/base/utils/param-processing'; import { getParamsData, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 208acb801ba..0899d254484 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2260,6 +2260,9 @@ importers: '@sap-ux/logger': specifier: workspace:* version: link:../logger + '@sap-ux/store': + specifier: workspace:* + version: link:../store '@types/mem-fs': specifier: 1.1.2 version: 1.1.2 @@ -3655,12 +3658,6 @@ importers: memfs: specifier: 3.4.13 version: 3.4.13 - ts-jest: - specifier: 29.4.1 - version: 29.4.1(@babel/core@7.28.0)(babel-jest@30.0.1)(esbuild@0.25.6)(jest@30.1.1)(typescript@5.9.2) - ts-node: - specifier: 10.9.2 - version: 10.9.2(@types/node@20.0.0)(typescript@5.9.2) unionfs: specifier: 4.4.0 version: 4.4.0 @@ -7587,7 +7584,7 @@ packages: slash: 3.0.0 dev: true - /@jest/core@30.1.1(ts-node@10.9.2): + /@jest/core@30.1.1: resolution: {integrity: sha512-3ncU9peZ3D2VdgRkdZtUceTrDgX5yiDRwAFjtxNfU22IiZrpVWlv/FogzDLYSJQptQGfFo3PcHK86a2oG6WUGg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: @@ -7609,7 +7606,7 @@ packages: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.0.5 - jest-config: 30.1.1(@types/node@20.0.0)(ts-node@10.9.2) + jest-config: 30.1.1(@types/node@20.0.0) jest-haste-map: 30.1.0 jest-message-util: 30.1.0 jest-regex-util: 30.0.1 @@ -18152,7 +18149,7 @@ packages: node-notifier: optional: true dependencies: - '@jest/core': 30.1.1(ts-node@10.9.2) + '@jest/core': 30.1.1 '@jest/test-result': 30.1.1 '@jest/types': 30.0.5 chalk: 4.1.2 @@ -18215,7 +18212,7 @@ packages: - supports-color dev: true - /jest-config@30.1.1(@types/node@20.0.0)(ts-node@10.9.2): + /jest-config@30.1.1(@types/node@20.0.0): resolution: {integrity: sha512-xuPGUGDw+9fPPnGmddnLnHS/mhKUiJOW7K65vErYmglEPKq65NKwSRchkQ7iv6gqjs2l+YNEsAtbsplxozdOWg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: @@ -18255,7 +18252,6 @@ packages: pretty-format: 30.0.5 slash: 3.0.0 strip-json-comments: 3.1.1 - ts-node: 10.9.2(@types/node@20.0.0)(typescript@5.9.2) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -18860,7 +18856,7 @@ packages: node-notifier: optional: true dependencies: - '@jest/core': 30.1.1(ts-node@10.9.2) + '@jest/core': 30.1.1 '@jest/types': 30.0.5 import-local: 3.2.0 jest-cli: 30.1.1(@types/node@18.11.9)