diff --git a/messages/secrets-redacted.md b/messages/secrets-redacted.md new file mode 100644 index 00000000..898a7dbd --- /dev/null +++ b/messages/secrets-redacted.md @@ -0,0 +1,19 @@ +# redacted.accessToken + +[REDACTED] Use 'sf org auth show-access-token' to view + +# redacted.sfdxAuthUrl + +[REDACTED] Use 'sf org auth show-sfdx-auth-url' to view + +# redacted.userPassword + +[REDACTED] Use 'sf org auth show-user-password' to view + +# temp.envVarWorkaround + +Secrets are now hidden from '%s' command output. Use the 'sf org auth' commands instead. As a temporary workaround, you can set SF_TEMP_SHOW_SECRETS=true to render these secrets. This workaround will be removed in an upcoming release. + +# temp.envVarIsSet + +The SF_TEMP_SHOW_SECRETS env var is set. This is a temporary env var to continue to show secrets in the '%s' command output. This workaround will be removed in an upcoming CLI release. Switch to use the 'sf org auth' commands to avoid future disruption. diff --git a/src/commands/org/create/scratch.ts b/src/commands/org/create/scratch.ts index 4e32af56..99170494 100644 --- a/src/commands/org/create/scratch.ts +++ b/src/commands/org/create/scratch.ts @@ -16,6 +16,7 @@ import { MultiStageOutput } from '@oclif/multi-stage-output'; import { + envVars, Lifecycle, Messages, Org, @@ -26,7 +27,7 @@ import { SfError, } from '@salesforce/core'; import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; -import { Duration } from '@salesforce/kit'; +import { Duration, omit } from '@salesforce/kit'; import terminalLink from 'terminal-link'; import { capitalCase } from 'change-case'; import { buildScratchOrgRequest } from '../../../shared/scratchOrgRequest.js'; @@ -34,6 +35,7 @@ import { ScratchCreateResponse } from '../../../shared/orgTypes.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-org', 'create_scratch'); +const secretsMessages = Messages.loadMessages('@salesforce/plugin-org', 'secrets-redacted'); const definitionFileHelpGroupName = 'Definition File Override'; @@ -251,7 +253,36 @@ export default class OrgCreateScratch extends SfCommand { this.logSuccess(messages.getMessage('success')); } - return { username, scratchOrgInfo, authFields, warnings, orgId: authFields?.orgId }; + // TODO: Remove env var workaround + const showSecretsEnvVarIsSet = envVars.getBoolean('SF_TEMP_SHOW_SECRETS', false); + const accessTokenRedacted = secretsMessages.getMessage('redacted.accessToken'); + + const redactedAuthFields = authFields + ? { + ...authFields, + accessToken: showSecretsEnvVarIsSet ? authFields.accessToken : accessTokenRedacted, + refreshToken: undefined, + clientSecret: undefined, + } + : undefined; + + const redactedScratchOrgInfo = omit(scratchOrgInfo, ['AuthCode']); + + if (this.jsonEnabled()) { + if (showSecretsEnvVarIsSet) { + this.warn(secretsMessages.getMessage('temp.envVarIsSet', ['sf org create scratch --json'])); + } else { + this.warn(secretsMessages.getMessage('temp.envVarWorkaround', ['sf org create scratch --json'])); + } + } + + return { + username, + scratchOrgInfo: redactedScratchOrgInfo, + authFields: redactedAuthFields, + warnings, + orgId: authFields?.orgId, + }; } catch (error) { mso.error(); if (error instanceof SfError && error.name === 'ScratchOrgInfoTimeoutError') { diff --git a/src/commands/org/display.ts b/src/commands/org/display.ts index 8f370a2d..c518c703 100644 --- a/src/commands/org/display.ts +++ b/src/commands/org/display.ts @@ -21,7 +21,7 @@ import { loglevel, orgApiVersionFlagWithDeprecations, } from '@salesforce/sf-plugins-core'; -import { AuthInfo, Messages, Org, SfError, trimTo15 } from '@salesforce/core'; +import { AuthInfo, envVars, Messages, Org, SfError, trimTo15 } from '@salesforce/core'; import { camelCaseToTitleCase } from '@salesforce/kit'; import { AuthFieldsFromFS, OrgDisplayReturn, ScratchOrgFields } from '../../shared/orgTypes.js'; import { getAliasByUsername } from '../../shared/utils.js'; @@ -31,6 +31,8 @@ import { OrgListUtil } from '../../shared/orgListUtil.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-org', 'display'); const sharedMessages = Messages.loadMessages('@salesforce/plugin-org', 'messages'); +const secretsMessages = Messages.loadMessages('@salesforce/plugin-org', 'secrets-redacted'); + export class OrgDisplayCommand extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); @@ -54,12 +56,22 @@ export class OrgDisplayCommand extends SfCommand { this.org = flags['target-org']; this.org.getConnection(flags['api-version']); try { + // TODO: Once env var is removed, this refresh could be removed. // the auth file might have a stale access token. We want to refresh it before getting the fields await this.org.refreshAuth(); } catch (error) { // even if this fails, we want to display the information we can read from the auth file this.warn('unable to refresh auth for org'); } + + const accessTokenRedactedMessage = secretsMessages.getMessage('redacted.accessToken'); + const sfdxAuthUrlRedactedMessage = secretsMessages.getMessage('redacted.sfdxAuthUrl'); + const passwordRedactedMessage = secretsMessages.getMessage('redacted.userPassword'); + + const envVarAsATempWorkaroundMessage = secretsMessages.getMessage('temp.envVarWorkaround', ['sf org display']); + const showSecretsEnvVarIsSet = envVars.getBoolean('SF_TEMP_SHOW_SECRETS', false); + const envVarIsSetWarning = secretsMessages.getMessage('temp.envVarIsSet', ['sf org display']); + // translate to alias if necessary const authInfo = await AuthInfo.create({ username: this.org.getUsername() }); const fields = authInfo.getFields(true) as AuthFieldsFromFS; @@ -67,6 +79,14 @@ export class OrgDisplayCommand extends SfCommand { const isScratchOrg = Boolean(fields.devHubUsername); const scratchOrgInfo = isScratchOrg && fields.orgId ? await this.getScratchOrgInformation(fields) : {}; + const getSfdxAuthUrlOutput = (): string | undefined => { + if (flags.verbose && fields.refreshToken) { + // TODO: Remove env var workaround + return showSecretsEnvVarIsSet ? authInfo.getSfdxAuthUrl() : sfdxAuthUrlRedactedMessage; + } + return undefined; + }; + const returnValue: OrgDisplayReturn = { // renamed properties id: fields.orgId, @@ -74,22 +94,33 @@ export class OrgDisplayCommand extends SfCommand { // copied properties apiVersion: fields.instanceApiVersion, - accessToken: fields.accessToken, + // Access token will always exist, don't check for value first + // TODO: Remove env var workaround + accessToken: showSecretsEnvVarIsSet ? fields.accessToken : accessTokenRedactedMessage, instanceUrl: fields.instanceUrl, username: fields.username, clientId: fields.clientId, - password: fields.password, + // Password only exists if it was generated locally, check for value first + // TODO: Remove env var workaround + password: fields.password ? (showSecretsEnvVarIsSet ? fields.password : passwordRedactedMessage) : undefined, ...scratchOrgInfo, - // properties with more complex logic connectedStatus: isScratchOrg ? undefined : await OrgListUtil.determineConnectedStatusForNonScratchOrg(fields.username), - sfdxAuthUrl: flags.verbose && fields.refreshToken ? authInfo.getSfdxAuthUrl() : undefined, + sfdxAuthUrl: getSfdxAuthUrlOutput(), alias: await getAliasByUsername(fields.username), + // TODO: deprecate these values clientApps: fields.clientApps ? Object.keys(fields.clientApps).join(',') : undefined, }; - this.warn(sharedMessages.getMessage('SecurityWarning')); + + if (showSecretsEnvVarIsSet) { + this.warn(envVarIsSetWarning); + this.warn(sharedMessages.getMessage('SecurityWarning')); + } else { + this.warn(envVarAsATempWorkaroundMessage); + } + this.print(returnValue); return returnValue; } diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index ae5c4e02..e58669ee 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -16,7 +16,17 @@ import { EOL } from 'node:os'; import { Flags, loglevel, SfCommand } from '@salesforce/sf-plugins-core'; -import { AuthInfo, ConfigAggregator, ConfigInfo, Connection, Org, SfError, Messages, Logger } from '@salesforce/core'; +import { + AuthInfo, + ConfigAggregator, + ConfigInfo, + Connection, + envVars, + Org, + SfError, + Messages, + Logger, +} from '@salesforce/core'; import { Interfaces } from '@oclif/core'; import ansis, { type Ansis } from 'ansis'; import { OrgListUtil, identifyActiveOrgByStatus } from '../../shared/orgListUtil.js'; @@ -25,6 +35,7 @@ import { ExtendedAuthFields, FullyPopulatedScratchOrgFields } from '../../shared Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-org', 'list'); +const secretsMessages = Messages.loadMessages('@salesforce/plugin-org', 'secrets-redacted'); export const defaultOrgEmoji = '🍁'; export const defaultHubEmoji = '🌳'; @@ -126,6 +137,17 @@ Legend: ${defaultHubEmoji}=Default DevHub, ${defaultOrgEmoji}=Default Org ${ }` ); + // TODO: Remove after env var workaround is removed + const showSecretsEnvVarIsSet = envVars.getBoolean('SF_TEMP_SHOW_SECRETS', false); + + if (this.jsonEnabled()) { + if (showSecretsEnvVarIsSet) { + this.warn(secretsMessages.getMessage('temp.envVarIsSet', ['sf org list'])); + } else { + this.warn(secretsMessages.getMessage('temp.envVarWorkaround', ['sf org list'])); + } + } + return result; } diff --git a/src/commands/org/resume/scratch.ts b/src/commands/org/resume/scratch.ts index 82d81290..beb66989 100644 --- a/src/commands/org/resume/scratch.ts +++ b/src/commands/org/resume/scratch.ts @@ -18,6 +18,7 @@ import { strict as assert } from 'node:assert'; import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; import { + envVars, Lifecycle, Messages, ScratchOrgCache, @@ -30,10 +31,12 @@ import { import terminalLink from 'terminal-link'; import { MultiStageOutput } from '@oclif/multi-stage-output'; import { capitalCase } from 'change-case'; +import { omit } from '@salesforce/kit'; import { ScratchCreateResponse } from '../../../shared/orgTypes.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-org', 'resume_scratch'); +const secretsMessages = Messages.loadMessages('@salesforce/plugin-org', 'secrets-redacted'); export default class OrgResumeScratch extends SfCommand { public static readonly summary = messages.getMessage('summary'); @@ -127,7 +130,37 @@ export default class OrgResumeScratch extends SfCommand { this.log(); this.logSuccess(messages.getMessage('success')); - return { username, scratchOrgInfo, authFields, warnings, orgId: authFields?.orgId }; + + // TODO: Remove env var workaround + const showSecretsEnvVarIsSet = envVars.getBoolean('SF_TEMP_SHOW_SECRETS', false); + const accessTokenRedacted = secretsMessages.getMessage('redacted.accessToken'); + + const redactedAuthFields = authFields + ? { + ...authFields, + accessToken: showSecretsEnvVarIsSet ? authFields.accessToken : accessTokenRedacted, + refreshToken: undefined, + clientSecret: undefined, + } + : undefined; + + const redactedScratchOrgInfo = scratchOrgInfo ? omit(scratchOrgInfo, ['AuthCode']) : undefined; + + if (this.jsonEnabled()) { + if (showSecretsEnvVarIsSet) { + this.warn(secretsMessages.getMessage('temp.envVarIsSet', ['sf org resume scratch --json'])); + } else { + this.warn(secretsMessages.getMessage('temp.envVarWorkaround', ['sf org resume scratch --json'])); + } + } + + return { + username, + scratchOrgInfo: redactedScratchOrgInfo, + authFields: redactedAuthFields, + warnings, + orgId: authFields?.orgId, + }; } catch (e) { mso.error(); diff --git a/src/shared/orgListUtil.ts b/src/shared/orgListUtil.ts index 1a0cad9f..751eccb4 100644 --- a/src/shared/orgListUtil.ts +++ b/src/shared/orgListUtil.ts @@ -20,8 +20,10 @@ import fs from 'node:fs/promises'; import { Org, AuthInfo, + envVars, Global, Logger, + Messages, SfError, trimTo15, ConfigAggregator, @@ -38,6 +40,9 @@ import type { AuthFieldsFromFS, } from './orgTypes.js'; +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const secretsMessages = Messages.loadMessages('@salesforce/plugin-org', 'secrets-redacted'); + type OrgGroups = { nonScratchOrgs: ExtendedAuthFields[]; scratchOrgs: ExtendedAuthFieldsScratch[]; @@ -101,12 +106,27 @@ export class OrgListUtil { OrgListUtil.processScratchOrgs(orgs.scratchOrgs), ]); + // TODO: Remove env var workaround + const showSecretsEnvVarIsSet = envVars.getBoolean('SF_TEMP_SHOW_SECRETS', false); + const redactSecrets = (org: T): T => ({ + ...org, + accessToken: showSecretsEnvVarIsSet ? org.accessToken : secretsMessages.getMessage('redacted.accessToken'), + password: org.password + ? showSecretsEnvVarIsSet + ? org.password + : secretsMessages.getMessage('redacted.userPassword') + : undefined, + }); + + const redactedNonScratchOrgs = nonScratchOrgs.map(redactSecrets); + const redactedScratchOrgs = scratchOrgs.map(redactSecrets); + return { - nonScratchOrgs, - scratchOrgs, - sandboxes: nonScratchOrgs.filter(sandboxFilter), - other: nonScratchOrgs.filter(regularOrgFilter), - devHubs: nonScratchOrgs.filter(devHubFilter), + nonScratchOrgs: redactedNonScratchOrgs, + scratchOrgs: redactedScratchOrgs, + sandboxes: redactedNonScratchOrgs.filter(sandboxFilter), + other: redactedNonScratchOrgs.filter(regularOrgFilter), + devHubs: redactedNonScratchOrgs.filter(devHubFilter), }; } @@ -404,7 +424,7 @@ const authErrorHandler = async (err: unknown, username: string): Promise const logger = await OrgListUtil.retrieveLogger(); logger.trace(`error refreshing auth for org: ${username}`); logger.trace(error); - // Orgs under maintenace return html as the error message. + // Orgs under maintenance return html as the error message. if (error.message.includes('maintenance')) return 'Down (Maintenance)'; // handle other potential html responses if (error.message.includes('') || error.message.includes('')) return 'Bad Response'; diff --git a/test/shared/orgListUtil.test.ts b/test/shared/orgListUtil.test.ts index 0e3006bf..cc49d3c6 100644 --- a/test/shared/orgListUtil.test.ts +++ b/test/shared/orgListUtil.test.ts @@ -277,6 +277,46 @@ describe('orgListUtil tests', () => { expect(orgGroups.nonScratchOrgs[0].connectedStatus).to.equal('bad file'); expect(checkNonScratchOrgIsDevHub.called).to.be.false; }); + + describe('secret redaction', () => { + it('redacts access tokens in nonScratchOrgs', async () => { + const orgs = await OrgListUtil.readLocallyValidatedMetaConfigsGroupedByOrgType(fileNames, false); + expect(orgs.nonScratchOrgs[0].accessToken).to.include('[REDACTED]'); + }); + + it('redacts access tokens in scratchOrgs', async () => { + const orgs = await OrgListUtil.readLocallyValidatedMetaConfigsGroupedByOrgType(fileNames, false); + for (const org of orgs.scratchOrgs) { + expect(org.accessToken).to.include('[REDACTED]'); + } + }); + + it('redacts access tokens in devHubs', async () => { + const orgs = await OrgListUtil.readLocallyValidatedMetaConfigsGroupedByOrgType(fileNames, false); + for (const org of orgs.devHubs) { + expect(org.accessToken).to.include('[REDACTED]'); + } + }); + }); + + describe('secret redaction WITH env var (SF_TEMP_SHOW_SECRETS)', () => { + const SHOW_TOKENS_ENV = 'SF_TEMP_SHOW_SECRETS'; + + beforeEach(() => { + process.env[SHOW_TOKENS_ENV] = 'true'; + }); + + afterEach(() => { + delete process.env[SHOW_TOKENS_ENV]; + }); + + it('shows real access tokens in scratchOrgs', async () => { + const orgs = await OrgListUtil.readLocallyValidatedMetaConfigsGroupedByOrgType(fileNames, false); + for (const org of orgs.scratchOrgs) { + expect(org.accessToken).to.not.include('[REDACTED]'); + } + }); + }); }); describe('auth file reading tests', () => { diff --git a/test/unit/org/display.test.ts b/test/unit/org/display.test.ts index 7614d84b..8605d208 100644 --- a/test/unit/org/display.test.ts +++ b/test/unit/org/display.test.ts @@ -37,7 +37,7 @@ describe('org:display', () => { const commonAssert = (result: OrgDisplayReturn) => { expect(result).to.have.property('username', testOrg.username); expect(result).to.have.property('id', testOrg.orgId); - expect(result).to.have.property('accessToken', testOrg.accessToken); + expect(result.accessToken).to.include('[REDACTED]'); expect(result).to.have.property('instanceUrl', testOrg.instanceUrl); expect(result).to.have.property('clientId', testOrg.clientId); }; @@ -63,9 +63,13 @@ describe('org:display', () => { value: testOrg.clientId, }); + const accessToken = data.find((row) => row.key === 'Access Token'); + expect(accessToken).to.exist; + expect(accessToken?.value).to.include('[REDACTED]'); + const authUrl = data.find((row) => row.key === 'Sfdx Auth Url'); expect(authUrl).to.exist; - expect(authUrl?.value).to.include(testOrg.clientId); + expect(authUrl?.value).to.include('[REDACTED]'); }); it('includes correct rows in non-json (table) mode', async () => { @@ -186,12 +190,13 @@ describe('org:display', () => { expect(result.alias).to.equal('nonscratchalias'); }); - it('displays authUrl when using refresh token AND verbose', async () => { + it('redacts authUrl when using refresh token AND verbose', async () => { testOrg.refreshToken = refreshToken; await $$.stubAuths(testOrg); const result = await OrgDisplayCommand.run(['--json', '--targetusername', testOrg.username, '--verbose']); - expect(result.sfdxAuthUrl).to.include(testOrg.refreshToken); + expect(result.sfdxAuthUrl).to.include('[REDACTED]'); + expect(result.sfdxAuthUrl).to.include('show-sfdx-auth-url'); }); it('omits authUrl when not using refresh token, despite verbose', async () => { @@ -220,13 +225,14 @@ describe('org:display', () => { expect(result.alias).to.be.undefined; }); - it('displays decrypted password if password exists', async () => { + it('redacts password if password exists', async () => { testOrg.password = 'encrypted'; await $$.stubAuths(testOrg); const result = await OrgDisplayCommand.run(['--json', '--targetusername', testOrg.username]); expect(commonAssert(result)); - expect(result.password).to.equal('encrypted'); + expect(result.password).to.include('[REDACTED]'); + expect(result.password).to.include('show-user-password'); }); it('queries server for scratch org info', async () => { @@ -256,9 +262,57 @@ describe('org:display', () => { expect(result.status).to.equal('Active'); }); + it('emits the workaround warning referencing sf org display', async () => { + await $$.stubAuths(testOrg); + await OrgDisplayCommand.run(['--targetusername', testOrg.username]); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls.some((w) => typeof w === 'string' && w.includes('sf org display'))).to.be.true; + expect(warnCalls.some((w) => typeof w === 'string' && w.includes('SF_TEMP_SHOW_SECRETS'))).to.be.true; + }); + it('gets non-scratch org connectedStatus'); it('handles properly when username is an accessToken?'); it('displays good error when org is not connectable due to DNS'); it('displays scratch-org-only properties for scratch orgs'); it('displays no scratch-org-only properties for non-scratch orgs'); + + describe('secret redaction WITH env var (SF_TEMP_SHOW_SECRETS)', () => { + const SHOW_TOKENS_ENV = 'SF_TEMP_SHOW_SECRETS'; + + beforeEach(() => { + process.env[SHOW_TOKENS_ENV] = 'true'; + }); + + afterEach(() => { + delete process.env[SHOW_TOKENS_ENV]; + }); + + it('shows the real access token', async () => { + await $$.stubAuths(testOrg); + const result = await OrgDisplayCommand.run(['--json', '--targetusername', testOrg.username]); + expect(result.accessToken).to.equal(testOrg.accessToken); + }); + + it('shows the real sfdx auth url when --verbose and refresh token exist', async () => { + testOrg.refreshToken = refreshToken; + await $$.stubAuths(testOrg); + const result = await OrgDisplayCommand.run(['--json', '--targetusername', testOrg.username, '--verbose']); + expect(result.sfdxAuthUrl).to.include(refreshToken); + }); + + it('shows the real password when one exists', async () => { + testOrg.password = 'somepassword'; + await $$.stubAuths(testOrg); + const result = await OrgDisplayCommand.run(['--json', '--targetusername', testOrg.username]); + expect(result.password).to.equal('somepassword'); + }); + + it('emits the deprecation warning referencing sf org display', async () => { + await $$.stubAuths(testOrg); + await OrgDisplayCommand.run(['--targetusername', testOrg.username]); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls.some((w) => typeof w === 'string' && w.includes('will be removed'))).to.be.true; + expect(warnCalls.some((w) => typeof w === 'string' && w.includes('sf org display'))).to.be.true; + }); + }); }); diff --git a/test/unit/org/list.test.ts b/test/unit/org/list.test.ts index 40a44f28..ba5c3ea8 100644 --- a/test/unit/org/list.test.ts +++ b/test/unit/org/list.test.ts @@ -28,10 +28,11 @@ describe('org:list', () => { // There is no need to call $$.restore() in afterEach() since that is // done automatically by the TestContext. const $$ = new TestContext(); + let sfCommandUxStubs: ReturnType; beforeEach(() => { // Stub the ux methods on SfCommand so that you don't get any command output in your tests. - stubSfCommandUx($$.SANDBOX); + sfCommandUxStubs = stubSfCommandUx($$.SANDBOX); stubMethod($$.SANDBOX, AuthInfo, 'listAllAuthorizations').resolves([ 'Jimi Hendrix', 'SRV', @@ -94,4 +95,49 @@ describe('org:list', () => { expect(spies.get('orgRemove').callCount).to.equal(2); // there are 2 expired scratch orgs }); }); + + describe('secret redaction warnings', () => { + beforeEach(() => { + stubMethod($$.SANDBOX, OrgListUtil, 'readLocallyValidatedMetaConfigsGroupedByOrgType').resolves( + OrgListMock.AUTH_INFO + ); + }); + + it('emits the workaround warning referencing sf org list when --json is used', async () => { + await OrgListCommand.run(['--json']); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls.some((w) => typeof w === 'string' && w.includes('sf org list'))).to.be.true; + expect(warnCalls.some((w) => typeof w === 'string' && w.includes('SF_TEMP_SHOW_SECRETS'))).to.be.true; + expect(warnCalls.some((w) => typeof w === 'string' && w.includes('sf org auth'))).to.be.true; + }); + + it('does not emit the secrets warning without --json', async () => { + await OrgListCommand.run([]); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls.some((w) => typeof w === 'string' && w.includes('SF_TEMP_SHOW_SECRETS'))).to.be.false; + }); + }); + + describe('secret redaction warnings WITH env var (SF_TEMP_SHOW_SECRETS)', () => { + const SHOW_TOKENS_ENV = 'SF_TEMP_SHOW_SECRETS'; + + beforeEach(() => { + process.env[SHOW_TOKENS_ENV] = 'true'; + stubMethod($$.SANDBOX, OrgListUtil, 'readLocallyValidatedMetaConfigsGroupedByOrgType').resolves( + OrgListMock.AUTH_INFO + ); + }); + + afterEach(() => { + delete process.env[SHOW_TOKENS_ENV]; + }); + + it('emits the deprecation warning referencing sf org list when --json is used', async () => { + await OrgListCommand.run(['--json']); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls.some((w) => typeof w === 'string' && w.includes('will be removed'))).to.be.true; + expect(warnCalls.some((w) => typeof w === 'string' && w.includes('sf org list'))).to.be.true; + expect(warnCalls.some((w) => typeof w === 'string' && w.includes('sf org auth'))).to.be.true; + }); + }); });