From 94117b00b66aeced86b137d293cd6458c53d907d Mon Sep 17 00:00:00 2001 From: Harshith Rai Date: Tue, 28 Jul 2026 19:17:04 +0530 Subject: [PATCH 1/3] fix: prevent dry-run crash on clientAuthCredentials handler --- .../auth0/handlers/clientAuthCredentials.ts | 10 ++++++- .../handlers/clientAuthCredentialsPre.ts | 8 +++++- test/tools/auth0/handlers/dryRun.tests.ts | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/tools/auth0/handlers/clientAuthCredentials.ts b/src/tools/auth0/handlers/clientAuthCredentials.ts index 23194fb38..a73bbd7d1 100644 --- a/src/tools/auth0/handlers/clientAuthCredentials.ts +++ b/src/tools/auth0/handlers/clientAuthCredentials.ts @@ -1,5 +1,5 @@ import { order } from './default'; -import { Assets, Auth0APIClient } from '../../../types'; +import { Assets, Auth0APIClient, CalculatedChanges } from '../../../types'; import { ConfigFunction } from '../../../configFactory'; import { paginate } from '../client'; import log from '../../../logger'; @@ -39,6 +39,14 @@ export default class ClientAuthCredentialsHandler { return null; } + // Credentials are per-client sub-resources with no top-level asset list, so they are not + // modeled in the dry-run diff. Return empty changes so the dry-run loop (which calls + // dryRunChanges on every handler) does not crash. Credential changes are still applied + // during a real import via processChanges. + async dryRunChanges(_assets: Assets): Promise { + return { del: [], create: [], conflicts: [], update: [] }; + } + @order('70') async processChanges(assets: Assets): Promise { const { clients } = assets; diff --git a/src/tools/auth0/handlers/clientAuthCredentialsPre.ts b/src/tools/auth0/handlers/clientAuthCredentialsPre.ts index 2b3e14ce3..af9412b7e 100644 --- a/src/tools/auth0/handlers/clientAuthCredentialsPre.ts +++ b/src/tools/auth0/handlers/clientAuthCredentialsPre.ts @@ -1,5 +1,5 @@ import { order } from './default'; -import { Assets, Auth0APIClient } from '../../../types'; +import { Assets, Auth0APIClient, CalculatedChanges } from '../../../types'; import { ConfigFunction } from '../../../configFactory'; import { paginate } from '../client'; import log from '../../../logger'; @@ -47,6 +47,12 @@ export default class ClientAuthCredentialsPreHandler { return null; } + // Deploy-only pre-pass handler with no dry-run representation. Return empty changes so the + // dry-run loop (which calls dryRunChanges on every registered handler) does not crash. + async dryRunChanges(_assets: Assets): Promise { + return { del: [], create: [], conflicts: [], update: [] }; + } + @order('40') async processChanges(assets: Assets): Promise { const { clients } = assets; diff --git a/test/tools/auth0/handlers/dryRun.tests.ts b/test/tools/auth0/handlers/dryRun.tests.ts index 39b02e0e8..4b837dc1e 100644 --- a/test/tools/auth0/handlers/dryRun.tests.ts +++ b/test/tools/auth0/handlers/dryRun.tests.ts @@ -6,6 +6,8 @@ import DatabasesHandler from '../../../../src/tools/auth0/handlers/databases'; import HooksHandler from '../../../../src/tools/auth0/handlers/hooks'; import RulesConfigsHandler from '../../../../src/tools/auth0/handlers/rulesConfigs'; import RulesHandler from '../../../../src/tools/auth0/handlers/rules'; +import ClientAuthCredentialsHandler from '../../../../src/tools/auth0/handlers/clientAuthCredentials'; +import ClientAuthCredentialsPreHandler from '../../../../src/tools/auth0/handlers/clientAuthCredentialsPre'; import DefaultHandler from '../../../../src/tools/auth0/handlers/default'; import constants from '../../../../src/tools/constants'; import { configFactory } from '../../../../src/configFactory'; @@ -299,6 +301,32 @@ describe('#handler dryRunChanges', () => { expect(changes.update).to.have.length(1); expect(changes.update[0].secrets).to.equal(undefined); }); + + it('clientAuthCredentials should return empty changes without crashing during dry run', async () => { + const handler = new ClientAuthCredentialsHandler({ + client: pageClient({ pool } as any), + config, + } as any); + + const changes = await handler.dryRunChanges({ + clients: [{ name: 'my-app', client_authentication_methods: {} }], + } as any); + + expect(changes).to.deep.equal({ del: [], create: [], conflicts: [], update: [] }); + }); + + it('clientAuthCredentialsPre should return empty changes without crashing during dry run', async () => { + const handler = new ClientAuthCredentialsPreHandler({ + client: pageClient({ pool } as any), + config, + } as any); + + const changes = await handler.dryRunChanges({ + clients: [{ name: 'my-app' }], + } as any); + + expect(changes).to.deep.equal({ del: [], create: [], conflicts: [], update: [] }); + }); }); describe('#getResourceName', () => { From 155e53c9cd350fffcfa2f564bba8f04833014d8b Mon Sep 17 00:00:00 2001 From: Harshith Rai Date: Wed, 29 Jul 2026 21:00:50 +0530 Subject: [PATCH 2/3] harden: guard dry-run loop against handlers missing dryRunChanges --- src/tools/auth0/index.ts | 12 ++++++++++++ test/tools/auth0/index.test.ts | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/tools/auth0/index.ts b/src/tools/auth0/index.ts index 558d50e8b..19794a870 100644 --- a/src/tools/auth0/index.ts +++ b/src/tools/auth0/index.ts @@ -21,6 +21,7 @@ import { ConfigFunction } from '../../configFactory'; import { dryRunFormatAssets, exportDiffLog } from '../calculateDryRunChanges'; import { isTruthy } from '../../utils'; import { printCLIMessage } from '../utils'; +import log from '../../logger'; export type Stage = 'load' | 'validate' | 'processChanges'; @@ -185,6 +186,17 @@ export default class Auth0 { generator: (handler) => (async () => { try { + // Every handler in the dry-run loop is expected to implement dryRunChanges. + // Guard against non-conforming handlers (e.g. deploy-only handlers that don't + // extend the base APIHandler) so a missing method skips the handler instead of + // crashing the whole dry run. Logged loudly so an omitted preview is visible. + if (typeof handler.dryRunChanges !== 'function') { + log.warn( + `Handler "${handler.type}" does not implement dryRunChanges; skipping it in the dry-run preview. Changes for this resource will still be applied during a real import.` + ); + return; + } + const detailedChanges: DetailedDryRunChange[] = []; let created = 0; let updated = 0; diff --git a/test/tools/auth0/index.test.ts b/test/tools/auth0/index.test.ts index 1c04be44f..96cc606b0 100644 --- a/test/tools/auth0/index.test.ts +++ b/test/tools/auth0/index.test.ts @@ -322,4 +322,23 @@ describe('#Auth0 class', () => { expect(output).to.include('./tenant-config-directory/'); }); }); + + describe('#dryRunChanges handler conformance', () => { + // Auth0.dryRun calls dryRunChanges on every handler in the assembled handlers array. A handler + // that does not implement it crashes the entire dry run (see ESD-64945: the standalone + // clientAuthCredentials handlers did not extend the base APIHandler). Assert at build time that + // every registered handler conforms, so a new standalone handler cannot silently regress. + it('every registered handler implements dryRunChanges', () => { + const auth0 = new Auth0(mockEmptyClient, mockEmptyAssets, () => undefined); + + const nonConforming = auth0.handlers + .filter((handler) => typeof handler.dryRunChanges !== 'function') + .map((handler) => handler.type); + + expect( + nonConforming, + `handlers missing dryRunChanges: ${nonConforming.join(', ')}` + ).to.have.length(0); + }); + }); }); From dc98a8f8981c7c22b2045a2f0b0a11d17c87c1b4 Mon Sep 17 00:00:00 2001 From: Harshith Rai Date: Wed, 29 Jul 2026 23:11:57 +0530 Subject: [PATCH 3/3] test: cover dry-run guard for handlers missing dryRunChanges --- test/tools/auth0/index.test.ts | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/tools/auth0/index.test.ts b/test/tools/auth0/index.test.ts index 96cc606b0..77c226a9a 100644 --- a/test/tools/auth0/index.test.ts +++ b/test/tools/auth0/index.test.ts @@ -4,6 +4,7 @@ import Auth0 from '../../../src/tools/auth0'; import * as calculateDryRunChanges from '../../../src/tools/calculateDryRunChanges'; import * as utils from '../../../src/tools/utils'; import { Auth0APIClient, Assets } from '../../../src/types'; +import log from '../../../src/logger'; const mockEmptyClient = { prompts: { @@ -169,6 +170,47 @@ describe('#Auth0 class', () => { expect(output).to.include('No changes detected'); }); + it('should skip a handler that does not implement dryRunChanges instead of crashing', async () => { + process.env.AUTH0_DEBUG = 'true'; + + sandbox + .stub(calculateDryRunChanges, 'dryRunFormatAssets') + .callsFake(async (assets) => assets); + + const printedMessages: string[] = []; + sandbox.stub(utils, 'printCLIMessage').callsFake((message: string) => { + printedMessages.push(message); + }); + + const warnStub = sandbox.stub(log, 'warn'); + + const auth0 = new Auth0(mockEmptyClient, mockEmptyAssets, (key) => { + const config = { + AUTH0_DOMAIN: 'example-tenant.auth0.com', + AUTH0_INPUT_FILE: './tenant.yaml', + }; + return config[key]; + }); + + // A deploy-only handler with no dryRunChanges alongside a conforming one — the run must + // warn and skip the former rather than throw "handler.dryRunChanges is not a function". + auth0.handlers = [ + { type: 'clientAuthCredentials' }, + { + type: 'clients', + dryRunChanges: async () => ({ create: [], update: [], del: [] }), + getResourceName: (item: { name: string }) => item.name, + }, + ] as any; + + const hasChanges = await auth0.dryRun(); + + expect(hasChanges).to.equal(false); + expect(warnStub.calledOnce).to.equal(true); + expect(warnStub.firstCall.args[0]).to.include('clientAuthCredentials'); + expect(warnStub.firstCall.args[0]).to.include('does not implement dryRunChanges'); + }); + it('should show DELETE with asterisk note when AUTH0_ALLOW_DELETE is false', async () => { process.env.AUTH0_DEBUG = 'true';