From 023b7bc185d7cf10e50f89eedfabd59c9031c3dd Mon Sep 17 00:00:00 2001 From: Eric Willhoit Date: Wed, 23 Sep 2026 16:09:12 -0500 Subject: [PATCH] feat: add ca and at telem --- src/commandExecution.ts | 58 +++++++- test/commandExecution.test.ts | 271 +++++++++++++++++++++++++++++++++- test/helpers/myOrgCommand.ts | 42 ++++++ 3 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 test/helpers/myOrgCommand.ts diff --git a/src/commandExecution.ts b/src/commandExecution.ts index dfdf14df..2dc6ea46 100644 --- a/src/commandExecution.ts +++ b/src/commandExecution.ts @@ -17,7 +17,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { Config, Command, Flags, Parser } from '@oclif/core'; -import { Org, SfError } from '@salesforce/core'; +import { Org, SfError, matchesJwtAccessToken, matchesOpaqueAccessToken } from '@salesforce/core'; import { AsyncCreatable } from '@salesforce/kit'; import { isNumber, JsonMap, Optional } from '@salesforce/ts-types'; import { parseVarArgs } from '@salesforce/sf-plugins-core'; @@ -35,6 +35,37 @@ type PluginInfo = { version: Optional; }; +export type AccessTokenType = 'jwt' | 'opaque' | 'unknown'; + +export const classifyAccessToken = (token: string | undefined): AccessTokenType | undefined => { + if (!token) return undefined; + if (matchesJwtAccessToken(token)) return 'jwt'; + if (matchesOpaqueAccessToken(token)) return 'opaque'; + return 'unknown'; +}; + +/** + * Salesforce-owned OAuth client IDs we intentionally surface in telemetry, mapped + * to the stable label reported on the event. Anything not in this map (a customer's + * own connected app) is reported as `undefined` so a customer consumer key is never + * emitted. This mirrors core's log filter, which leaves `PlatformCLI` visible and + * redacts every other clientId. + * + * `'PlatformCLI'` and `'CodeBuilder'` are core's `DEFAULT_CONNECTED_APP_INFO.clientId` + * and `CODE_BUILDER_CONNECTED_APP_INFO.clientId`; those constants are not re-exported + * from `@salesforce/core`, so the (stable, public) literals are used here. Add the + * Global ECA framework client id once its value is confirmed. + */ +export type KnownClientId = 'PlatformCLI' | 'CodeBuilder'; + +const KNOWN_CLIENT_IDS: Record = { + PlatformCLI: 'PlatformCLI', + CodeBuilder: 'CodeBuilder', +}; + +export const classifyKnownClientId = (clientId: string | undefined): KnownClientId | undefined => + clientId ? KNOWN_CLIENT_IDS[clientId] : undefined; + export class CommandExecution extends AsyncCreatable { public status?: number; private specifiedFlags: string[] = []; @@ -50,6 +81,10 @@ export class CommandExecution extends AsyncCreatable { private agentPseudoTypeUsed?: boolean; private orgApiVersion?: string; private devhubApiVersion?: string; + private orgAccessTokenType?: AccessTokenType; + private devhubAccessTokenType?: AccessTokenType; + private orgKnownClientId?: KnownClientId; + private devhubKnownClientId?: KnownClientId; private argKeys: string[] = []; private enableO11y?: boolean; private o11yUploadEndpoint?: string; @@ -120,6 +155,10 @@ export class CommandExecution extends AsyncCreatable { devhubId: this.devhubId, orgApiVersion: this.orgApiVersion, devhubApiVersion: this.devhubApiVersion, + orgAccessTokenType: this.orgAccessTokenType, + devhubAccessTokenType: this.devhubAccessTokenType, + orgKnownClientId: this.orgKnownClientId, + devhubKnownClientId: this.devhubKnownClientId, specifiedEnvs: envs.specifiedEnvs.join(' '), uniqueEnvs: envs.uniqueEnvs.join(' '), argKeys: this.argKeys.sort().join(' '), @@ -186,6 +225,8 @@ export class CommandExecution extends AsyncCreatable { this.devhubId = targetDevHub ? targetDevHub.getOrgId() : undefined; this.orgApiVersion = targetOrg ? targetOrg.getConnection().getApiVersion() : undefined; this.devhubApiVersion = targetDevHub ? targetDevHub.getConnection().getApiVersion() : undefined; + this.setAccessTokenTypes(targetOrg, targetDevHub); + this.setKnownClientIds(targetOrg, targetDevHub); this.determineSpecifiedFlags(argv, flags, flagDefinitions); // Read o11y configuration from the plugin's package.json (plugin that owns the command) @@ -195,6 +236,21 @@ export class CommandExecution extends AsyncCreatable { } } + // Classify the in-memory access token format for the resolved target org and target dev hub. + private setAccessTokenTypes(targetOrg: Optional, targetDevHub: Optional): void { + this.orgAccessTokenType = classifyAccessToken(targetOrg?.getConnection().getConnectionOptions().accessToken); + this.devhubAccessTokenType = classifyAccessToken(targetDevHub?.getConnection().getConnectionOptions().accessToken); + } + + // Report only Salesforce-owned OAuth client IDs (see KNOWN_CLIENT_IDS); a custom + // connected app resolves to undefined so no customer consumer key is emitted. + // Reads the already-in-memory auth fields (no extra disk read, no decrypt: clientId + // is stored in plaintext) off the same connection used above. + private setKnownClientIds(targetOrg: Optional, targetDevHub: Optional): void { + this.orgKnownClientId = classifyKnownClientId(targetOrg?.getConnection().getAuthInfoFields()?.clientId); + this.devhubKnownClientId = classifyKnownClientId(targetDevHub?.getConnection().getAuthInfoFields()?.clientId); + } + // Get and set the O11y configuration from the plugin's package.json private async setO11yConfig(pluginRoot: string): Promise { try { diff --git a/test/commandExecution.test.ts b/test/commandExecution.test.ts index 3d457682..c97f982b 100644 --- a/test/commandExecution.test.ts +++ b/test/commandExecution.test.ts @@ -18,12 +18,14 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { Command } from '@oclif/core'; import { Interfaces, Performance } from '@oclif/core'; +import type { Connection, Org } from '@salesforce/core'; import { stubInterface, stubMethod } from '@salesforce/ts-sinon'; import { expect } from 'chai'; import sinon from 'sinon'; -import { CommandExecution } from '../src/commandExecution.js'; +import { classifyAccessToken, classifyKnownClientId, CommandExecution } from '../src/commandExecution.js'; import { MyCommand } from './helpers/myCommand.js'; import { MyArgCommand } from './helpers/myArgCommand.js'; +import { MyOrgCommand, orgFlagState } from './helpers/myOrgCommand.js'; describe('toJson', () => { const sandbox = sinon.createSandbox(); @@ -431,4 +433,271 @@ describe('toJson', () => { expect(actual.productFeatureId).to.equal(undefined); }); }); + + describe('classifyAccessToken', () => { + it('classifies a JWT-shaped access token as jwt', () => { + expect( + classifyAccessToken( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyfQ.signaturepart' + ) + ).to.equal('jwt'); + }); + + it('classifies an opaque access token as opaque', () => { + expect(classifyAccessToken('00D5f000000abcd!AQEAQxyz.longtail')).to.equal('opaque'); + }); + + it('classifies a non-matching string as unknown', () => { + expect(classifyAccessToken('this-is-not-a-real-token')).to.equal('unknown'); + }); + + it('returns undefined for an empty string', () => { + expect(classifyAccessToken('')).to.equal(undefined); + }); + + it('returns undefined for undefined', () => { + expect(classifyAccessToken(undefined)).to.equal(undefined); + }); + }); + + describe('orgAccessTokenType / devhubAccessTokenType', () => { + afterEach(() => { + orgFlagState.targetOrg = undefined; + orgFlagState.targetDevHub = undefined; + }); + + const fakeOrgWithToken = (accessToken: string): Org => { + const connection = stubInterface(sandbox, { + getConnectionOptions: () => ({ accessToken }), + getApiVersion: () => '62.0', + }); + return stubInterface(sandbox, { + getConnection: () => connection, + getOrgId: () => '00D000000000000EAA', + getUsername: () => 'me@example.com', + }) as unknown as Org; + }; + + it('sets orgAccessTokenType to jwt when the target-org token is JWT-shaped', async () => { + process.env.CI = 'true'; + orgFlagState.targetOrg = fakeOrgWithToken( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyfQ.signaturepart' + ); + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: ['--target-org', 'myOrg'], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.orgAccessTokenType).to.equal('jwt'); + }); + + it('sets orgAccessTokenType to opaque when the target-org token is opaque', async () => { + process.env.CI = 'true'; + orgFlagState.targetOrg = fakeOrgWithToken('00D5f000000abcd!AQEAQxyz.longtail'); + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: ['--target-org', 'myOrg'], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.orgAccessTokenType).to.equal('opaque'); + }); + + it('sets devhubAccessTokenType to jwt when the target-dev-hub token is JWT-shaped', async () => { + process.env.CI = 'true'; + orgFlagState.targetDevHub = fakeOrgWithToken( + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyfQ.signaturepart' + ); + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: ['--target-dev-hub', 'myHub'], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.devhubAccessTokenType).to.equal('jwt'); + }); + + it('sets devhubAccessTokenType to opaque when the target-dev-hub token is opaque', async () => { + process.env.CI = 'true'; + orgFlagState.targetDevHub = fakeOrgWithToken('00D5f000000abcd!AQEAQxyz.longtail'); + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: ['--target-dev-hub', 'myHub'], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.devhubAccessTokenType).to.equal('opaque'); + }); + + it('leaves orgAccessTokenType and devhubAccessTokenType undefined when there is no resolved org/dev-hub', async () => { + process.env.CI = 'true'; + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: [], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.orgAccessTokenType).to.equal(undefined); + expect(actual.devhubAccessTokenType).to.equal(undefined); + }); + + it('leaves orgAccessTokenType undefined when the resolved org has no access token in memory', async () => { + process.env.CI = 'true'; + const connection = stubInterface(sandbox, { + getConnectionOptions: () => ({}), + getApiVersion: () => '62.0', + }); + orgFlagState.targetOrg = stubInterface(sandbox, { + getConnection: () => connection, + getOrgId: () => '00D000000000000EAA', + getUsername: () => 'me@example.com', + }) as unknown as Org; + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: ['--target-org', 'myOrg'], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.orgAccessTokenType).to.equal(undefined); + }); + }); + + describe('classifyKnownClientId', () => { + it('maps the PlatformCLI client id to PlatformCLI', () => { + expect(classifyKnownClientId('PlatformCLI')).to.equal('PlatformCLI'); + }); + + it('maps the CodeBuilder client id to CodeBuilder', () => { + expect(classifyKnownClientId('CodeBuilder')).to.equal('CodeBuilder'); + }); + + it('returns undefined for a custom (non-allowlisted) client id', () => { + expect(classifyKnownClientId('3MVG9custom.connected.app.consumer.key')).to.equal(undefined); + }); + + it('returns undefined for an empty string', () => { + expect(classifyKnownClientId('')).to.equal(undefined); + }); + + it('returns undefined for undefined', () => { + expect(classifyKnownClientId(undefined)).to.equal(undefined); + }); + }); + + describe('orgKnownClientId / devhubKnownClientId', () => { + afterEach(() => { + orgFlagState.targetOrg = undefined; + orgFlagState.targetDevHub = undefined; + }); + + const fakeOrgWithClientId = (clientId?: string): Org => { + const connection = stubInterface(sandbox, { + getConnectionOptions: () => ({}), + getAuthInfoFields: () => (clientId ? { clientId } : {}), + getApiVersion: () => '62.0', + }); + return stubInterface(sandbox, { + getConnection: () => connection, + getOrgId: () => '00D000000000000EAA', + getUsername: () => 'me@example.com', + }) as unknown as Org; + }; + + it('sets orgKnownClientId to PlatformCLI when the target-org uses the default CLI connected app', async () => { + process.env.CI = 'true'; + orgFlagState.targetOrg = fakeOrgWithClientId('PlatformCLI'); + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: ['--target-org', 'myOrg'], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.orgKnownClientId).to.equal('PlatformCLI'); + }); + + it('sets orgKnownClientId to CodeBuilder when the target-org uses the Code Builder connected app', async () => { + process.env.CI = 'true'; + orgFlagState.targetOrg = fakeOrgWithClientId('CodeBuilder'); + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: ['--target-org', 'myOrg'], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.orgKnownClientId).to.equal('CodeBuilder'); + }); + + it('leaves orgKnownClientId undefined when the target-org uses a custom connected app', async () => { + process.env.CI = 'true'; + orgFlagState.targetOrg = fakeOrgWithClientId('3MVG9custom.connected.app.consumer.key'); + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: ['--target-org', 'myOrg'], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.orgKnownClientId).to.equal(undefined); + }); + + it('sets devhubKnownClientId to PlatformCLI when the target-dev-hub uses the default CLI connected app', async () => { + process.env.CI = 'true'; + orgFlagState.targetDevHub = fakeOrgWithClientId('PlatformCLI'); + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: ['--target-dev-hub', 'myHub'], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.devhubKnownClientId).to.equal('PlatformCLI'); + }); + + it('leaves orgKnownClientId and devhubKnownClientId undefined when there is no resolved org/dev-hub', async () => { + process.env.CI = 'true'; + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: [], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.orgKnownClientId).to.equal(undefined); + expect(actual.devhubKnownClientId).to.equal(undefined); + }); + + it('leaves orgKnownClientId undefined when the resolved org has no clientId in memory', async () => { + process.env.CI = 'true'; + orgFlagState.targetOrg = fakeOrgWithClientId(); + const config = stubInterface(sandbox, {}); + const execution = await CommandExecution.create({ + argv: ['--target-org', 'myOrg'], + command: MyOrgCommand, + config, + }); + const actual = execution.toJson(); + + expect(actual.orgKnownClientId).to.equal(undefined); + }); + }); }); diff --git a/test/helpers/myOrgCommand.ts b/test/helpers/myOrgCommand.ts new file mode 100644 index 00000000..585674ec --- /dev/null +++ b/test/helpers/myOrgCommand.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Org } from '@salesforce/core'; +import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; + +/** + * Tests set these before calling `CommandExecution.create` to control what + * the `target-org` / `target-dev-hub` flags resolve to, since the real flags + * do a network/filesystem-backed org resolution that we don't want in unit tests. + */ +export const orgFlagState: { targetOrg?: Org; targetDevHub?: Org } = {}; + +export class MyOrgCommand extends SfCommand { + public static id = 'test:org'; + public static flags = { + 'target-org': Flags.custom()({ + parse: async () => orgFlagState.targetOrg, + }), + 'target-dev-hub': Flags.custom()({ + parse: async () => orgFlagState.targetDevHub, + }), + }; + + // eslint-disable-next-line class-methods-use-this + public async run(): Promise { + return Promise.resolve(); + } +}