From f43562430616c49f10a9fa5ec56350b680116f96 Mon Sep 17 00:00:00 2001 From: Takashi Arai Date: Fri, 12 Jul 2024 15:14:15 -0700 Subject: [PATCH] feat: save identity data to sf's config and to the server (#89) * feat: save identity data to sf's config and to the server * Update messages/shared.utils.md Co-authored-by: Kevin Hawkins * chore: update after code review * chore: fix tests * chore: touch up after rebase * chore: refactor usage of connection object. fix more tests * chore: remove interface * chore: nit * chore: nit --------- Co-authored-by: Kevin Hawkins --- messages/lightning.preview.app.md | 12 +++ messages/shared.utils.md | 4 +- src/commands/lightning/preview/app.ts | 60 +++++++++++++-- src/configMeta.ts | 12 +-- src/lwc-dev-server/index.ts | 12 +-- src/shared/configUtils.ts | 82 +++++++++++++------- src/shared/previewUtils.ts | 25 +++++- test/commands/lightning/preview/app.test.ts | 35 ++++++++- test/lwc-dev-server/index.e2e.test.ts | 5 +- test/lwc-dev-server/index.test.ts | 3 +- test/shared/configUtils.test.ts | 70 +++++++++++------ test/shared/previewUtils.test.ts | 85 +++++++++++++++++++-- 12 files changed, 322 insertions(+), 83 deletions(-) diff --git a/messages/lightning.preview.app.md b/messages/lightning.preview.app.md index 2b9620f..f11fb8b 100644 --- a/messages/lightning.preview.app.md +++ b/messages/lightning.preview.app.md @@ -35,6 +35,18 @@ Type of device to emulate in preview. For mobile virtual devices, specify the device ID to preview. If omitted, the first available virtual device will be used. +# error.username + +Org must have a valid user + +# error.identitydata + +Couldn't find identity data while generating preview arguments + +# error.identitydata.entityid + +Couldn't find entity ID while generating preview arguments + # error.no-project This command is required to run from within a Salesforce project directory. %s diff --git a/messages/shared.utils.md b/messages/shared.utils.md index 447a82d..4063c01 100644 --- a/messages/shared.utils.md +++ b/messages/shared.utils.md @@ -14,9 +14,9 @@ The workspace name of the local lwc dev server Valid workspace value is "SalesforceCLI" OR "mrt" -# config-utils.token-desc +# config-utils.data-desc -The Base64-encoded identity token of the local web server +The identity data is a data structure that links the local web server's identity token to the user's configured Salesforce orgs. # config-utils.cert-desc diff --git a/src/commands/lightning/preview/app.ts b/src/commands/lightning/preview/app.ts index ed854de..7f1a3f9 100644 --- a/src/commands/lightning/preview/app.ts +++ b/src/commands/lightning/preview/app.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import * as readline from 'node:readline'; -import { Logger, Messages, SfProject } from '@salesforce/core'; +import { Connection, Logger, Messages, SfProject } from '@salesforce/core'; import { AndroidAppPreviewConfig, AndroidVirtualDevice, @@ -21,6 +21,7 @@ import chalk from 'chalk'; import { OrgUtils } from '../../../shared/orgUtils.js'; import { startLWCServer } from '../../../lwc-dev-server/index.js'; import { PreviewUtils } from '../../../shared/previewUtils.js'; +import { ConfigUtils, IdentityTokenService } from '../../../shared/configUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'lightning.preview.app'); @@ -38,6 +39,22 @@ export const androidSalesforceAppPreviewConfig = { const maxInt32 = 2_147_483_647; // maximum 32-bit signed integer value +class AppServerIdentityTokenService implements IdentityTokenService { + private connection: Connection; + public constructor(connection: Connection) { + this.connection = connection; + } + + public async saveTokenToServer(token: string): Promise { + const sobject = this.connection.sobject('UserLocalWebServerIdentity'); + const result = await sobject.insert({ LocalWebServerIdentityToken: token }); + if (result.success) { + return result.id; + } + throw new Error('Could not save the token to the server'); + } +} + export default class LightningPreviewApp extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); @@ -157,13 +174,23 @@ export default class LightningPreviewApp extends SfCommand { return Promise.reject(new Error(messages.getMessage('error.no-project', [(error as Error)?.message ?? '']))); } + logger.debug('Configuring local web server identity'); + const connection = targetOrg.getConnection(undefined); + const username = connection.getUsername(); + if (!username) { + return Promise.reject(new Error(messages.getMessage('error.username'))); + } + + const tokenService = new AppServerIdentityTokenService(connection); + const token = await ConfigUtils.getOrCreateIdentityToken(username, tokenService); + let appId: string | undefined; if (appName) { logger.debug(`Determining App Id for ${appName}`); // The appName is optional but if the user did provide an appName then it must be // a valid one.... meaning that it should resolve to a valid appId. - appId = await OrgUtils.getAppId(targetOrg.getConnection(undefined), appName); + appId = await OrgUtils.getAppId(connection, appName); if (!appId) { return Promise.reject(new Error(messages.getMessage('error.fetching.app-id', [appName]))); } @@ -179,13 +206,17 @@ export default class LightningPreviewApp extends SfCommand { const ldpServerUrl = PreviewUtils.generateWebSocketUrlForLocalDevServer(platform, serverPort, logger); logger.debug(`Local Dev Server url is ${ldpServerUrl}`); + const entityId = await PreviewUtils.getEntityId(username); + if (platform === Platform.desktop) { - await this.desktopPreview(sfdxProjectRootPath, serverPort, ldpServerUrl, appId, logger); + await this.desktopPreview(sfdxProjectRootPath, serverPort, token, entityId, ldpServerUrl, appId, logger); } else { await this.mobilePreview( platform, sfdxProjectRootPath, serverPort, + token, + entityId, ldpServerUrl, appName, appId, @@ -198,6 +229,8 @@ export default class LightningPreviewApp extends SfCommand { private async desktopPreview( sfdxProjectRootPath: string, serverPort: number, + token: string, + entityId: string, ldpServerUrl: string, appId: string | undefined, logger: Logger @@ -230,10 +263,15 @@ export default class LightningPreviewApp extends SfCommand { this.log(`\n${messages.getMessage('trust.local.dev.server')}`); } - const launchArguments = PreviewUtils.generateDesktopPreviewLaunchArguments(ldpServerUrl, appId, targetOrg); + const launchArguments = PreviewUtils.generateDesktopPreviewLaunchArguments( + ldpServerUrl, + entityId, + appId, + targetOrg + ); // Start the LWC Dev Server - await startLWCServer(logger, sfdxProjectRootPath, serverPort); + await startLWCServer(logger, sfdxProjectRootPath, token, serverPort); // Open the browser and navigate to the right page await this.config.runCommand('org:open', launchArguments); @@ -243,6 +281,8 @@ export default class LightningPreviewApp extends SfCommand { platform: Platform.ios | Platform.android, sfdxProjectRootPath: string, serverPort: number, + token: string, + entityId: string, ldpServerUrl: string, appName: string | undefined, appId: string | undefined, @@ -316,11 +356,17 @@ export default class LightningPreviewApp extends SfCommand { } // Start the LWC Dev Server - await startLWCServer(logger, sfdxProjectRootPath, serverPort, certData); + + await startLWCServer(logger, sfdxProjectRootPath, token, serverPort, certData); // Launch the native app for previewing (launchMobileApp will show its own spinner) // eslint-disable-next-line camelcase - appConfig.launch_arguments = PreviewUtils.generateMobileAppPreviewLaunchArguments(ldpServerUrl, appName, appId); + appConfig.launch_arguments = PreviewUtils.generateMobileAppPreviewLaunchArguments( + ldpServerUrl, + entityId, + appName, + appId + ); await PreviewUtils.launchMobileApp(platform, appConfig, resolvedDeviceId, emulatorPort, bundlePath, logger); } finally { // stop progress & spinner UX (that may still be running in case of an error) diff --git a/src/configMeta.ts b/src/configMeta.ts index 010b7a8..9558ef0 100644 --- a/src/configMeta.ts +++ b/src/configMeta.ts @@ -10,7 +10,7 @@ import { ConfigPropertyMeta, ConfigValue, Messages } from '@salesforce/core'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'shared.utils'); -const IDENTITY_TOKEN_DESC = messages.getMessage('config-utils.token-desc'); +const IDENTITY_DATA_DESC = messages.getMessage('config-utils.data-desc'); const LOCAL_DEV_SERVER_CERT_DESC = messages.getMessage('config-utils.cert-desc'); const LOCAL_DEV_SERVER_CERT_ERROR_MESSAGE = messages.getMessage('config-utils.cert-error-message'); const LOCAL_DEV_SERVER_PORT_DESC = messages.getMessage('config-utils.port-desc'); @@ -27,10 +27,10 @@ export type SerializedSSLCertificateData = { export const enum ConfigVars { /** - * The Base64-encoded identity token of the local web server, used to - * validate the web server's identity to the hmr-client. + * The identity data is a data structure that links the local web server's + * identity token to the user's configured Salesforce orgs. */ - LOCAL_WEB_SERVER_IDENTITY_TOKEN = 'local-web-server-identity-token', + LOCAL_WEB_SERVER_IDENTITY_DATA = 'local-web-server-identity-data', /** * The SSL certificate data to be used by local dev server @@ -50,8 +50,8 @@ export const enum ConfigVars { export default [ { - key: ConfigVars.LOCAL_WEB_SERVER_IDENTITY_TOKEN, - description: IDENTITY_TOKEN_DESC, + key: ConfigVars.LOCAL_WEB_SERVER_IDENTITY_DATA, + description: IDENTITY_DATA_DESC, hidden: true, encrypted: true, }, diff --git a/src/lwc-dev-server/index.ts b/src/lwc-dev-server/index.ts index bccbc1c..32aa07b 100644 --- a/src/lwc-dev-server/index.ts +++ b/src/lwc-dev-server/index.ts @@ -46,10 +46,10 @@ function mapLogLevel(cliLogLevel: number): number { async function createLWCServerConfig( logger: Logger, rootDir: string, + token: string, serverPort?: number, certData?: SSLCertificateData, - workspace?: Workspace, - token?: string + workspace?: Workspace ): Promise { const sfdxConfig = path.resolve(rootDir, 'sfdx-project.json'); @@ -83,7 +83,7 @@ async function createLWCServerConfig( paths: namespacePaths, // use custom workspace if any is provided, or fetch from config file (if any), otherwise use the default workspace workspace: workspace ?? (await ConfigUtils.getLocalDevServerWorkspace()) ?? LOCAL_DEV_SERVER_DEFAULT_WORKSPACE, - identityToken: token ?? (await ConfigUtils.getOrCreateIdentityToken()), + identityToken: token, logLevel: mapLogLevel(logger.getLevel()), }; @@ -100,12 +100,12 @@ async function createLWCServerConfig( export async function startLWCServer( logger: Logger, rootDir: string, + token: string, serverPort?: number, certData?: SSLCertificateData, - workspace?: Workspace, - token?: string + workspace?: Workspace ): Promise { - const config = await createLWCServerConfig(logger, rootDir, serverPort, certData, workspace, token); + const config = await createLWCServerConfig(logger, rootDir, token, serverPort, certData, workspace); logger.trace(`Starting LWC Dev Server with config: ${JSON.stringify(config)}`); let lwcDevServer: LWCServer | null = await startLwcDevServer(config); diff --git a/src/shared/configUtils.ts b/src/shared/configUtils.ts index 7db49cf..6e2cbfb 100644 --- a/src/shared/configUtils.ts +++ b/src/shared/configUtils.ts @@ -10,20 +10,29 @@ import { CryptoUtils, SSLCertificateData } from '@salesforce/lwc-dev-mobile-core import { Config, ConfigAggregator } from '@salesforce/core'; import configMeta, { ConfigVars, SerializedSSLCertificateData } from './../configMeta.js'; +export type IdentityTokenService = { + saveTokenToServer(token: string): Promise; +}; + export const LOCAL_DEV_SERVER_DEFAULT_PORT = 8081; export const LOCAL_DEV_SERVER_DEFAULT_WORKSPACE = Workspace.SfCli; +export type LocalWebServerIdentityData = { + identityToken: string; + usernameToServerEntityIdMap: Record; +}; + export class ConfigUtils { - static #config: Config; + static #localConfig: Config; static #globalConfig: Config; - public static async getConfig(): Promise { - if (this.#config) { - return this.#config; + public static async getLocalConfig(): Promise { + if (this.#localConfig) { + return this.#localConfig; } - this.#config = await Config.create({ isGlobal: false }); + this.#localConfig = await Config.create({ isGlobal: false }); Config.addAllowedProperties(configMeta); - return this.#config; + return this.#localConfig; } public static async getGlobalConfig(): Promise { @@ -35,32 +44,39 @@ export class ConfigUtils { return this.#globalConfig; } - public static async getOrCreateIdentityToken(): Promise { - let token = await this.getIdentityToken(); - if (!token) { - token = CryptoUtils.generateIdentityToken(); - await this.writeIdentityToken(token); + public static async getOrCreateIdentityToken(username: string, tokenService: IdentityTokenService): Promise { + let identityData = await this.getIdentityData(); + if (!identityData) { + const token = CryptoUtils.generateIdentityToken(); + const entityId = await tokenService.saveTokenToServer(token); + identityData = { + identityToken: token, + usernameToServerEntityIdMap: {}, + }; + identityData.usernameToServerEntityIdMap[username] = entityId; + await this.writeIdentityData(identityData); + return token; + } else { + let entityId = identityData.usernameToServerEntityIdMap[username]; + if (!entityId) { + entityId = await tokenService.saveTokenToServer(identityData.identityToken); + identityData.usernameToServerEntityIdMap[username] = entityId; + await this.writeIdentityData(identityData); + } + return identityData.identityToken; } - return token; - } - - public static async getIdentityToken(): Promise { - const config = await ConfigAggregator.create({ customConfigMeta: configMeta }); - // Need to reload to make sure the values read are decrypted - await config.reload(); - const identityToken = config.getPropertyValue(ConfigVars.LOCAL_WEB_SERVER_IDENTITY_TOKEN); - - return identityToken as string; } - public static async writeIdentityToken(token: string): Promise { - const config = await this.getConfig(); - config.set(ConfigVars.LOCAL_WEB_SERVER_IDENTITY_TOKEN, token); + public static async writeIdentityData(identityData: LocalWebServerIdentityData): Promise { + const config = await this.getLocalConfig(); + // TODO: JSON needs to be stringified in order for config.write to encrypt. When config.write() + // can encrypt JSON data to write it into config we shall remove stringify(). + config.set(ConfigVars.LOCAL_WEB_SERVER_IDENTITY_DATA, JSON.stringify(identityData)); await config.write(); } public static async getCertData(): Promise { - const config = await this.getGlobalConfig(); + const config = await this.getLocalConfig(); const serializedData = config.get(ConfigVars.LOCAL_DEV_SERVER_HTTPS_CERT_DATA) as SerializedSSLCertificateData; if (serializedData) { const deserializedData: SSLCertificateData = { @@ -89,16 +105,28 @@ export class ConfigUtils { } public static async getLocalDevServerPort(): Promise { - const config = await this.getConfig(); + const config = await this.getLocalConfig(); const configPort = config.get(ConfigVars.LOCAL_DEV_SERVER_PORT) as number; return configPort; } public static async getLocalDevServerWorkspace(): Promise { - const config = await this.getConfig(); + const config = await this.getLocalConfig(); const configWorkspace = config.get(ConfigVars.LOCAL_DEV_SERVER_WORKSPACE) as Workspace; return configWorkspace; } + + public static async getIdentityData(): Promise { + const config = await ConfigAggregator.create({ customConfigMeta: configMeta }); + // Need to reload to make sure the values read are decrypted + await config.reload(); + const identityJson = config.getPropertyValue(ConfigVars.LOCAL_WEB_SERVER_IDENTITY_DATA); + + if (identityJson) { + return JSON.parse(identityJson as string) as LocalWebServerIdentityData; + } + return undefined; + } } diff --git a/src/shared/previewUtils.ts b/src/shared/previewUtils.ts index 6b3cdbf..908f6f8 100644 --- a/src/shared/previewUtils.ts +++ b/src/shared/previewUtils.ts @@ -162,6 +162,7 @@ export class PreviewUtils { * Generates the proper set of arguments to be used for launching desktop browser and navigating to the right location. * * @param ldpServerUrl The URL for the local dev server + * @param entityId Record ID for the identity token * @param appId An optional app id for a targeted LEX app * @param targetOrg An optional org id * @param auraMode An optional Aura Mode (defaults to DEVPREVIEW) @@ -169,6 +170,7 @@ export class PreviewUtils { */ public static generateDesktopPreviewLaunchArguments( ldpServerUrl: string, + entityId: string, appId?: string, targetOrg?: string, auraMode = DevPreviewAuraMode @@ -181,7 +183,10 @@ export class PreviewUtils { const appPath = appId ? `lightning/app/${appId}` : 'lightning'; // we prepend a '0.' to all of the params to ensure they will persist across browser redirects - const launchArguments = ['--path', `${appPath}?0.aura.ldpServerUrl=${ldpServerUrl}&0.aura.mode=${auraMode}`]; + const launchArguments = [ + '--path', + `${appPath}?0.aura.ldpServerUrl=${ldpServerUrl}&0.aura.ldpServerId=${entityId}&0.aura.mode=${auraMode}`, + ]; if (targetOrg) { launchArguments.push('--target-org', targetOrg); @@ -194,6 +199,7 @@ export class PreviewUtils { * Generates the proper set of arguments to be used for launching a mobile app with custom launch arguments. * * @param ldpServerUrl The URL for the local dev server + * @param entityId Record ID for the identity token * @param appName An optional app name for a targeted LEX app * @param appId An optional app id for a targeted LEX app * @param auraMode An optional Aura Mode (defaults to DEVPREVIEW) @@ -201,6 +207,7 @@ export class PreviewUtils { */ public static generateMobileAppPreviewLaunchArguments( ldpServerUrl: string, + entityId: string, appName?: string, appId?: string, auraMode = DevPreviewAuraMode @@ -219,6 +226,8 @@ export class PreviewUtils { launchArguments.push({ name: '0.aura.mode', value: auraMode }); + launchArguments.push({ name: '0.aura.ldpServerId', value: entityId }); + return launchArguments; } @@ -495,4 +504,18 @@ export class PreviewUtils { logger?.debug(`Extracting archive ${zipFilePath}`); await CommonUtils.executeCommandAsync(cmd, logger); } + + public static async getEntityId(username: string): Promise { + const identityData = await ConfigUtils.getIdentityData(); + let entityId: string | undefined; + if (!identityData) { + return Promise.reject(new Error(messages.getMessage('error.identitydata'))); + } else { + entityId = identityData.usernameToServerEntityIdMap[username]; + if (!entityId) { + return Promise.reject(new Error(messages.getMessage('error.identitydata.entityid'))); + } + return entityId; + } + } } diff --git a/test/commands/lightning/preview/app.test.ts b/test/commands/lightning/preview/app.test.ts index 4585c5f..b597f88 100644 --- a/test/commands/lightning/preview/app.test.ts +++ b/test/commands/lightning/preview/app.test.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import { Config as OclifConfig } from '@oclif/core'; -import { Config as SfConfig, Messages } from '@salesforce/core'; +import { Config as SfConfig, Messages, Connection } from '@salesforce/core'; import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; import { AndroidVirtualDevice, @@ -25,6 +25,7 @@ import LightningPreviewApp, { } from '../../../../src/commands/lightning/preview/app.js'; import { OrgUtils } from '../../../../src/shared/orgUtils.js'; import { PreviewUtils } from '../../../../src/shared/previewUtils.js'; +import { ConfigUtils, LocalWebServerIdentityData } from '../../../../src/shared/configUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); @@ -52,6 +53,14 @@ describe('lightning preview app', () => { const testEmulatorPort = 1234; let MockedLightningPreviewApp: typeof LightningPreviewApp; + const fakeIdentityToken = 'PFT1vw8v65aXd2b9HFvZ3Zu4OcKZwjI60bq7BEjj5k4='; + const fakeEntityId = '1I9xx0000004ClkCAE'; + const fakeIdentityData: LocalWebServerIdentityData = { + identityToken: `${fakeIdentityToken}`, + usernameToServerEntityIdMap: {}, + }; + fakeIdentityData.usernameToServerEntityIdMap[testOrgData.username] = fakeEntityId; + beforeEach(async () => { stubUx($$.SANDBOX); stubSpinner($$.SANDBOX); @@ -62,6 +71,7 @@ describe('lightning preview app', () => { $$.SANDBOX.stub(SfConfig.prototype, 'get').returns(undefined); $$.SANDBOX.stub(SfConfig.prototype, 'set'); $$.SANDBOX.stub(SfConfig.prototype, 'write').resolves(); + $$.SANDBOX.stub(ConfigUtils, 'getOrCreateIdentityToken').resolves(fakeIdentityToken); MockedLightningPreviewApp = await esmock( '../../../../src/commands/lightning/preview/app.js', @@ -88,6 +98,16 @@ describe('lightning preview app', () => { } }); + it('throws when username not found', async () => { + try { + $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(undefined); + $$.SANDBOX.stub(Connection.prototype, 'getUsername').returns(undefined); + await MockedLightningPreviewApp.run(['--name', 'blah', '-o', testOrgData.username]); + } catch (err) { + expect(err).to.be.an('error').with.property('message', messages.getMessage('error.username')); + } + }); + it('throws when cannot determine ldp server url', async () => { try { $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); @@ -112,6 +132,8 @@ describe('lightning preview app', () => { async function verifyOrgOpen(expectedAppPath: string, appName?: string): Promise { $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); + $$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(fakeIdentityData); + const runCmdStub = $$.SANDBOX.stub(OclifConfig.prototype, 'runCommand').resolves(); if (appName) { await MockedLightningPreviewApp.run(['--name', appName, '-o', testOrgData.username]); @@ -124,7 +146,7 @@ describe('lightning preview app', () => { 'org:open', [ '--path', - `${expectedAppPath}?0.aura.ldpServerUrl=${testServerUrl}&0.aura.mode=DEVPREVIEW`, + `${expectedAppPath}?0.aura.ldpServerUrl=${testServerUrl}&0.aura.ldpServerId=${fakeEntityId}&0.aura.mode=DEVPREVIEW`, '--target-org', testOrgData.username, ], @@ -136,6 +158,7 @@ describe('lightning preview app', () => { it('throws when environment setup requirements are not met', async () => { $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); + $$.SANDBOX.stub(PreviewUtils, 'getEntityId').resolves(fakeEntityId); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'run').rejects(new Error('Requirement blah not met')); @@ -147,6 +170,7 @@ describe('lightning preview app', () => { it('throws when unable to fetch mobile device', async () => { $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); + $$.SANDBOX.stub(PreviewUtils, 'getEntityId').resolves(fakeEntityId); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'run').resolves(); @@ -160,6 +184,7 @@ describe('lightning preview app', () => { it('throws when device fails to boot', async () => { $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); + $$.SANDBOX.stub(PreviewUtils, 'getEntityId').resolves(fakeEntityId); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'run').resolves(); @@ -177,6 +202,7 @@ describe('lightning preview app', () => { it('throws when cannot generate certificate', async () => { $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); + $$.SANDBOX.stub(PreviewUtils, 'getEntityId').resolves(fakeEntityId); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'run').resolves(); @@ -196,6 +222,7 @@ describe('lightning preview app', () => { it('waits for user to manually install the certificate', async () => { $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); + $$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(fakeIdentityData); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'run').resolves(); @@ -240,6 +267,7 @@ describe('lightning preview app', () => { it('throws if user chooses not to install app on mobile device', async () => { $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); + $$.SANDBOX.stub(PreviewUtils, 'getEntityId').resolves(fakeEntityId); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'run').resolves(); @@ -272,6 +300,8 @@ describe('lightning preview app', () => { it('installs and launched app on mobile device', async () => { $$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(testAppId); $$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl); + $$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(fakeIdentityData); + $$.SANDBOX.stub(PreviewUtils, 'getEntityId').resolves(fakeEntityId); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'init').resolves(); $$.SANDBOX.stub(LwcDevMobileCoreSetup.prototype, 'run').resolves(); @@ -415,6 +445,7 @@ describe('lightning preview app', () => { // eslint-disable-next-line camelcase expectedAppConfig.launch_arguments = PreviewUtils.generateMobileAppPreviewLaunchArguments( expectedLdpServerUrl, + fakeEntityId, 'Sales', testAppId ); diff --git a/test/lwc-dev-server/index.e2e.test.ts b/test/lwc-dev-server/index.e2e.test.ts index 84cfed7..7bf9e87 100644 --- a/test/lwc-dev-server/index.e2e.test.ts +++ b/test/lwc-dev-server/index.e2e.test.ts @@ -46,8 +46,9 @@ // $$.SANDBOX.resetHistory(); // }); -// it('e2e', async () => { -// const server = await devServer.startLWCServer(logger, path.resolve(__dirname, './__mocks__')); +// it('e2e', async () => { +// const fakeIdentityToken = 'PFT1vw8v65aXd2b9HFvZ3Zu4OcKZwjI60bq7BEjj5k4='; +// const server = await devServer.startLWCServer(logger, path.resolve(__dirname, './__mocks__'), fakeIdentityToken); // expect(server).to.be.an.instanceOf(LWCServer); // server.stopServer(); diff --git a/test/lwc-dev-server/index.test.ts b/test/lwc-dev-server/index.test.ts index 7495ede..7606df7 100644 --- a/test/lwc-dev-server/index.test.ts +++ b/test/lwc-dev-server/index.test.ts @@ -55,7 +55,8 @@ describe('lwc-dev-server', () => { }); it('calling startLWCServer returns an LWCServer', async () => { - const s = await lwcDevServer.startLWCServer(logger, path.resolve(__dirname, './__mocks__')); + const fakeIdentityToken = 'PFT1vw8v65aXd2b9HFvZ3Zu4OcKZwjI60bq7BEjj5k4='; + const s = await lwcDevServer.startLWCServer(logger, path.resolve(__dirname, './__mocks__'), fakeIdentityToken); expect(s).to.equal(server); }); }); diff --git a/test/shared/configUtils.test.ts b/test/shared/configUtils.test.ts index b864c96..4a4ac14 100644 --- a/test/shared/configUtils.test.ts +++ b/test/shared/configUtils.test.ts @@ -7,68 +7,94 @@ import { expect } from 'chai'; import { Workspace } from '@lwc/lwc-dev-server'; -import { Config, ConfigAggregator } from '@salesforce/core'; +import { Config, ConfigAggregator, Connection } from '@salesforce/core'; import { TestContext } from '@salesforce/core/testSetup'; import { CryptoUtils } from '@salesforce/lwc-dev-mobile-core'; -import { ConfigUtils } from '../../src/shared/configUtils.js'; +import { ConfigUtils, LocalWebServerIdentityData, IdentityTokenService } from '../../src/shared/configUtils.js'; import { ConfigVars } from '../../src/configMeta.js'; describe('configUtils', () => { const $$ = new TestContext(); + const fakeIdentityToken = 'PFT1vw8v65aXd2b9HFvZ3Zu4OcKZwjI60bq7BEjj5k4='; + const username = 'SalesforceDeveloper'; + const fakeEntityId = 'entityId'; + + class TestIdentityTokenService implements IdentityTokenService { + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars + public saveTokenToServer(token: string): Promise { + return Promise.resolve(fakeEntityId); + } + } + const testTokenService = new TestIdentityTokenService(); afterEach(() => { $$.restore(); }); - it('getOrCreateIdentityToken resolves if token is found', async () => { - const fakeIdentityToken = 'fake identity token'; - $$.SANDBOX.stub(ConfigUtils, 'getIdentityToken').resolves(fakeIdentityToken); + it('getOrCreateIdentityToken resolves if identity data is found', async () => { + const identityData: LocalWebServerIdentityData = { + identityToken: fakeIdentityToken, + usernameToServerEntityIdMap: {}, + }; + identityData.usernameToServerEntityIdMap[username] = fakeEntityId; + $$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(identityData); + $$.SANDBOX.stub(Connection, 'create').resolves(Connection.prototype); + $$.SANDBOX.stub(ConfigUtils, 'writeIdentityData').resolves(); + + const resolved = await ConfigUtils.getOrCreateIdentityToken(username, testTokenService); - const resolved = await ConfigUtils.getOrCreateIdentityToken(); expect(resolved).to.equal(fakeIdentityToken); }); - it('getOrCreateIdentityToken resolves and writeIdentityToken is called when there is no token', async () => { - const fakeIdentityToken = 'fake identity token'; - $$.SANDBOX.stub(ConfigUtils, 'getIdentityToken').resolves(undefined); + it('getOrCreateIdentityToken resolves and writeIdentityData is called when there is no identity data', async () => { + $$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(undefined); $$.SANDBOX.stub(CryptoUtils, 'generateIdentityToken').resolves(fakeIdentityToken); - const writeIdentityTokenStub = $$.SANDBOX.stub(ConfigUtils, 'writeIdentityToken').resolves(); + const writeIdentityTokenStub = $$.SANDBOX.stub(ConfigUtils, 'writeIdentityData').resolves(); + + const resolved = await ConfigUtils.getOrCreateIdentityToken(username, testTokenService); - const resolved = await ConfigUtils.getOrCreateIdentityToken(); expect(resolved).to.equal(fakeIdentityToken); expect(writeIdentityTokenStub.calledOnce).to.be.true; }); - it('getIdentityToken resolves to undefined when identity token is not available', async () => { + it('getIdentityData resolves to undefined if identity data is not found', async () => { $$.SANDBOX.stub(ConfigAggregator, 'create').resolves(ConfigAggregator.prototype); $$.SANDBOX.stub(ConfigAggregator.prototype, 'reload').resolves(); $$.SANDBOX.stub(ConfigAggregator.prototype, 'getPropertyValue').returns(undefined); - const resolved = await ConfigUtils.getIdentityToken(); + const resolved = await ConfigUtils.getIdentityData(); expect(resolved).to.equal(undefined); }); - it('getIdentityToken resolves to a string when identity token is available', async () => { - const fakeIdentityToken = 'fake identity token'; + it('getIdentityData resolves when identity data is available', async () => { + const identityData: LocalWebServerIdentityData = { + identityToken: fakeIdentityToken, + usernameToServerEntityIdMap: {}, + }; + const stringifiedData = JSON.stringify(identityData); $$.SANDBOX.stub(ConfigAggregator, 'create').resolves(ConfigAggregator.prototype); $$.SANDBOX.stub(ConfigAggregator.prototype, 'reload').resolves(); - $$.SANDBOX.stub(ConfigAggregator.prototype, 'getPropertyValue').returns(fakeIdentityToken); + $$.SANDBOX.stub(ConfigAggregator.prototype, 'getPropertyValue').returns(stringifiedData); - const resolved = await ConfigUtils.getIdentityToken(); - expect(resolved).to.equal(fakeIdentityToken); + const resolved = await ConfigUtils.getIdentityData(); + expect(resolved).to.deep.equal(identityData); }); - it('writeIdentityToken resolves', async () => { - const fakeIdentityToken = 'fake identity token'; + it('writeIdentityData resolves', async () => { $$.SANDBOX.stub(Config, 'create').withArgs($$.SANDBOX.match.any).resolves(Config.prototype); $$.SANDBOX.stub(Config, 'addAllowedProperties').withArgs($$.SANDBOX.match.any); $$.SANDBOX.stub(Config.prototype, 'set').withArgs( - ConfigVars.LOCAL_WEB_SERVER_IDENTITY_TOKEN, + ConfigVars.LOCAL_WEB_SERVER_IDENTITY_DATA, $$.SANDBOX.match.string ); $$.SANDBOX.stub(Config.prototype, 'write').resolves(); + const identityData: LocalWebServerIdentityData = { + identityToken: fakeIdentityToken, + usernameToServerEntityIdMap: {}, + }; + identityData.usernameToServerEntityIdMap[username] = 'entityId'; - const resolved = await ConfigUtils.writeIdentityToken(fakeIdentityToken); + const resolved = await ConfigUtils.writeIdentityData(identityData); expect(resolved).to.equal(undefined); }); diff --git a/test/shared/previewUtils.test.ts b/test/shared/previewUtils.test.ts index 01a30c6..3c51a07 100644 --- a/test/shared/previewUtils.test.ts +++ b/test/shared/previewUtils.test.ts @@ -18,7 +18,12 @@ import { Platform, SSLCertificateData, } from '@salesforce/lwc-dev-mobile-core'; -import { ConfigUtils, LOCAL_DEV_SERVER_DEFAULT_PORT } from '../../src/shared/configUtils.js'; +import { Messages } from '@salesforce/core'; +import { + ConfigUtils, + LOCAL_DEV_SERVER_DEFAULT_PORT, + LocalWebServerIdentityData, +} from '../../src/shared/configUtils.js'; import { PreviewUtils } from '../../src/shared/previewUtils.js'; import { iOSSalesforceAppPreviewConfig, @@ -26,6 +31,7 @@ import { } from '../../src/commands/lightning/preview/app.js'; describe('previewUtils', () => { + const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'lightning.preview.app'); const $$ = new TestContext(); const testIOSDevice = new IOSSimulatorDevice( @@ -44,6 +50,10 @@ describe('previewUtils', () => { '34' ); + const username = 'SalesforceDeveloper'; + const fakeEntityId = '1I9xx0000004ClkCAE'; + const fakeIdentityToken = 'PFT1vw8v65aXd2b9HFvZ3Zu4OcKZwjI60bq7BEjj5k4='; + afterEach(() => { $$.restore(); }); @@ -107,34 +117,58 @@ describe('previewUtils', () => { }); it('generateDesktopPreviewLaunchArguments', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + $$.SANDBOX.stub(PreviewUtils as any, 'getEntityId') + .withArgs(username) + .resolves(fakeEntityId); + expect( - PreviewUtils.generateDesktopPreviewLaunchArguments('MyLdpServerUrl', 'MyAppId', 'MyTargetOrg', 'MyAuraMode') + PreviewUtils.generateDesktopPreviewLaunchArguments( + 'MyLdpServerUrl', + fakeEntityId, + 'MyAppId', + 'MyTargetOrg', + 'MyAuraMode' + ) ).to.deep.equal([ '--path', - 'lightning/app/MyAppId?0.aura.ldpServerUrl=MyLdpServerUrl&0.aura.mode=MyAuraMode', + `lightning/app/MyAppId?0.aura.ldpServerUrl=MyLdpServerUrl&0.aura.ldpServerId=${fakeEntityId}&0.aura.mode=MyAuraMode`, '--target-org', 'MyTargetOrg', ]); - expect(PreviewUtils.generateDesktopPreviewLaunchArguments('MyLdpServerUrl')).to.deep.equal([ + expect(PreviewUtils.generateDesktopPreviewLaunchArguments('MyLdpServerUrl', fakeEntityId)).to.deep.equal([ '--path', - 'lightning?0.aura.ldpServerUrl=MyLdpServerUrl&0.aura.mode=DEVPREVIEW', + `lightning?0.aura.ldpServerUrl=MyLdpServerUrl&0.aura.ldpServerId=${fakeEntityId}&0.aura.mode=DEVPREVIEW`, ]); }); it('generateMobileAppPreviewLaunchArguments', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + $$.SANDBOX.stub(PreviewUtils as any, 'getEntityId') + .withArgs(username) + .resolves(fakeEntityId); + expect( - PreviewUtils.generateMobileAppPreviewLaunchArguments('MyLdpServerUrl', 'MyAppName', 'MyAppId', 'MyAuraMode') + PreviewUtils.generateMobileAppPreviewLaunchArguments( + 'MyLdpServerUrl', + fakeEntityId, + 'MyAppName', + 'MyAppId', + 'MyAuraMode' + ) ).to.deep.equal([ { name: 'LightningExperienceAppName', value: 'MyAppName' }, { name: 'LightningExperienceAppID', value: 'MyAppId' }, { name: '0.aura.ldpServerUrl', value: 'MyLdpServerUrl' }, { name: '0.aura.mode', value: 'MyAuraMode' }, + { name: '0.aura.ldpServerId', value: fakeEntityId }, ]); - expect(PreviewUtils.generateMobileAppPreviewLaunchArguments('MyLdpServerUrl')).to.deep.equal([ + expect(PreviewUtils.generateMobileAppPreviewLaunchArguments('MyLdpServerUrl', fakeEntityId)).to.deep.equal([ { name: '0.aura.ldpServerUrl', value: 'MyLdpServerUrl' }, { name: '0.aura.mode', value: 'DEVPREVIEW' }, + { name: '0.aura.ldpServerId', value: fakeEntityId }, ]); }); @@ -212,4 +246,41 @@ describe('previewUtils', () => { ) ).to.be.false; }); + + it('getEntityId returns valid entity ID', async () => { + const identityData: LocalWebServerIdentityData = { + identityToken: fakeIdentityToken, + usernameToServerEntityIdMap: {}, + }; + identityData.usernameToServerEntityIdMap[username] = fakeEntityId; + $$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(identityData); + + const entityId = await PreviewUtils.getEntityId(username); + + expect(entityId).to.equal(fakeEntityId); + }); + + it('getEntityId throws when valid data does not exist', async () => { + $$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(undefined); + + try { + await PreviewUtils.getEntityId(username); + } catch (err) { + expect(err).to.be.an('error').with.property('message', messages.getMessage('error.identitydata')); + } + }); + + it('getEntityId throws when entity ID does not exist', async () => { + const identityData: LocalWebServerIdentityData = { + identityToken: fakeIdentityToken, + usernameToServerEntityIdMap: {}, + }; + $$.SANDBOX.stub(ConfigUtils, 'getIdentityData').resolves(identityData); + + try { + await PreviewUtils.getEntityId(username); + } catch (err) { + expect(err).to.be.an('error').with.property('message', messages.getMessage('error.identitydata.entityid')); + } + }); });