From 75b8150ecdc8090cc6cebc4ba4a646f210e5182e Mon Sep 17 00:00:00 2001 From: "ankitatripathi.mp@gmail.com" Date: Sun, 26 Jul 2026 00:32:28 +0530 Subject: [PATCH] fix: resolve spurious dry-run diffs for rules, themes, and hooks --- src/tools/auth0/handlers/hooks.ts | 2 +- src/tools/auth0/handlers/rules.ts | 29 +++-- src/tools/auth0/handlers/themes.ts | 32 +++++- test/tools/auth0/handlers/dryRun.tests.ts | 123 +++++++++++++++++++++- test/tools/auth0/handlers/themes.tests.js | 91 ++++++++++++++++ 5 files changed, 266 insertions(+), 11 deletions(-) diff --git a/src/tools/auth0/handlers/hooks.ts b/src/tools/auth0/handlers/hooks.ts index 0dbee8722..e55864ed3 100644 --- a/src/tools/auth0/handlers/hooks.ts +++ b/src/tools/auth0/handlers/hooks.ts @@ -182,7 +182,7 @@ export default class HooksHandler extends DefaultHandler { .then((hookWithCode) => this.client.hooks.secrets .get(hook.id) - .then(({ data: secrets }) => ({ ...hookWithCode, secrets })) + .then((secrets) => ({ ...hookWithCode, secrets })) ) ) ); diff --git a/src/tools/auth0/handlers/rules.ts b/src/tools/auth0/handlers/rules.ts index 13e8d040b..3d551b5ee 100644 --- a/src/tools/auth0/handlers/rules.ts +++ b/src/tools/auth0/handlers/rules.ts @@ -4,6 +4,7 @@ import { convertJsonToString, stripFields, duplicateItems, isDeprecatedError } f import DefaultHandler from './default'; import log from '../../../logger'; import { calculateChanges } from '../../calculateChanges'; +import { calculateDryRunChanges } from '../../calculateDryRunChanges'; import { Asset, Assets, CalculatedChanges } from '../../../types'; import { paginate } from '../client'; @@ -152,14 +153,28 @@ export default class RulesHandler extends DefaultHandler { } async dryRunChanges(assets: Assets): Promise { - const { del, update, create, conflicts } = await this.calcChanges(assets); + let { rules } = assets; - return { - del, - update, - create, - conflicts, - }; + if (!rules) { + return { del: [], create: [], conflicts: [], update: [] }; + } + + const excludedRules = (assets.exclude && assets.exclude.rules) || []; + rules = rules.filter((r) => !excludedRules.includes(r.name)); + + let existing = await this.getType(); + if (existing === null) { + return { del: [], create: [], conflicts: [], update: [] }; + } + existing = existing.filter((r) => !excludedRules.includes(r.name)); + + return calculateDryRunChanges({ + type: this.type, + assets: rules, + existing, + identifiers: this.identifiers, + ignoreDryRunFields: this.getEffectiveIgnoreDryRunFields(), + }); } async validate(assets: Assets): Promise { diff --git a/src/tools/auth0/handlers/themes.ts b/src/tools/auth0/handlers/themes.ts index abdcc3674..4b09afe6f 100644 --- a/src/tools/auth0/handlers/themes.ts +++ b/src/tools/auth0/handlers/themes.ts @@ -1,8 +1,9 @@ import { Management } from 'auth0'; -import { Assets } from '../../../types'; +import { Assets, CalculatedChanges } from '../../../types'; import log from '../../../logger'; import DefaultHandler, { order } from './default'; import { isDryRun } from '../../utils'; +import { calculateDryRunChanges } from '../../calculateDryRunChanges'; /** * Schema @@ -437,6 +438,35 @@ export default class ThemesHandler extends DefaultHandler { return theme.displayName || JSON.stringify(theme); } + async dryRunChanges(assets: Assets): Promise { + const { themes } = assets; + + if (!themes) { + return { del: [], create: [], conflicts: [], update: [] }; + } + + const existing = await this.getType(); + + // Themes are singletons — at most one per tenant. If the local theme has no + // themeId (stripped during export when --export_ids is not set), backfill it + // from the remote so the matcher can find a match without requiring --export_ids. + const normalizedThemes = themes.map((theme) => { + if (!theme.themeId && existing && existing.length > 0 && existing[0].themeId) { + return { ...theme, themeId: existing[0].themeId }; + } + return theme; + }); + + return calculateDryRunChanges({ + type: this.type, + assets: normalizedThemes, + // @ts-ignore + existing, + identifiers: this.identifiers, + ignoreDryRunFields: this.getEffectiveIgnoreDryRunFields(), + }); + } + async getType(): Promise { if (!this.existing) { this.existing = await this.getThemes(); diff --git a/test/tools/auth0/handlers/dryRun.tests.ts b/test/tools/auth0/handlers/dryRun.tests.ts index 39b02e0e8..262ed5e5a 100644 --- a/test/tools/auth0/handlers/dryRun.tests.ts +++ b/test/tools/auth0/handlers/dryRun.tests.ts @@ -6,6 +6,7 @@ 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 ThemesHandler from '../../../../src/tools/auth0/handlers/themes'; import DefaultHandler from '../../../../src/tools/auth0/handlers/default'; import constants from '../../../../src/tools/constants'; import { configFactory } from '../../../../src/configFactory'; @@ -276,7 +277,7 @@ describe('#handler dryRunChanges', () => { enabled: true, }), secrets: { - get: async () => ({ data: { SECRET_ONE: constants.HOOKS_HIDDEN_SECRET_VALUE } }), + get: async () => ({ SECRET_ONE: constants.HOOKS_HIDDEN_SECRET_VALUE }), }, }, pool, @@ -299,12 +300,130 @@ describe('#handler dryRunChanges', () => { expect(changes.update).to.have.length(1); expect(changes.update[0].secrets).to.equal(undefined); }); + + it('rules should produce no changes on a clean export/dry-run cycle', async () => { + const auth0 = { + rules: { + list: (params: any) => + mockPagedData(params, 'rules', [ + { + id: 'rule-id-1', + name: 'Add voucherID to AccessToken', + order: 1, + stage: 'login_success', + enabled: true, + script: 'function (user, context, callback) { callback(null, user, context); }', + }, + { + id: 'rule-id-2', + name: 'Block Implicit Grant', + order: 2, + stage: 'login_success', + enabled: true, + script: 'function (user, context, callback) { callback(null, user, context); }', + }, + ]), + }, + pool, + }; + + const handler = new RulesHandler({ + client: pageClient(auth0 as any), + config, + } as any); + + // Simulate what export produces: rules by name, no id + const changes = await handler.dryRunChanges({ + rules: [ + { + name: 'Add voucherID to AccessToken', + order: 1, + stage: 'login_success', + enabled: true, + script: 'function (user, context, callback) { callback(null, user, context); }', + }, + { + name: 'Block Implicit Grant', + order: 2, + stage: 'login_success', + enabled: true, + script: 'function (user, context, callback) { callback(null, user, context); }', + }, + ], + } as any); + + expect(changes.create).to.have.length(0); + expect(changes.update).to.have.length(0); + expect(changes.del).to.have.length(0); + }); + + it('themes should not propose create/delete when local theme lacks themeId (exported without --export_ids)', async () => { + const remoteTheme = { + themeId: 'remote-theme-id', + displayName: 'Default Theme', + colors: { primary_button: '#635dff', error: '#d32f2f' }, + }; + + const auth0 = { + branding: { + themes: { + getDefault: async () => remoteTheme, + }, + }, + }; + + const handler = new ThemesHandler({ client: auth0, config } as any); + + const changes = await handler.dryRunChanges({ + themes: [ + { + // no themeId — mirrors a default export where AUTH0_EXPORT_IDENTIFIERS is false + displayName: 'Default Theme', + colors: { primary_button: '#635dff', error: '#d32f2f' }, + }, + ], + } as any); + + expect(changes.create).to.have.length(0); + expect(changes.del).to.have.length(0); + }); + + it('themes should propose update (not create/delete) when content differs and local theme lacks themeId', async () => { + const remoteTheme = { + themeId: 'remote-theme-id', + displayName: 'Default Theme', + colors: { primary_button: '#635dff', error: '#d32f2f' }, + }; + + const auth0 = { + branding: { + themes: { + getDefault: async () => remoteTheme, + }, + }, + }; + + const handler = new ThemesHandler({ client: auth0, config } as any); + + const changes = await handler.dryRunChanges({ + themes: [ + { + displayName: 'Default Theme', + colors: { primary_button: '#ff0000', error: '#d32f2f' }, // primary_button changed + }, + ], + } as any); + + expect(changes.create).to.have.length(0); + expect(changes.del).to.have.length(0); + expect(changes.update).to.have.length(1); + }); }); describe('#getResourceName', () => { function createHandler(type: string): DefaultHandler { + // @ts-ignore test stub — omits required runtime fields (client, functions, ignoreDryRunFields) return new DefaultHandler({ - // @ts-ignore test stub config: configFactory(), type, id: 'id', diff --git a/test/tools/auth0/handlers/themes.tests.js b/test/tools/auth0/handlers/themes.tests.js index 4b11f2a02..68d8f152f 100644 --- a/test/tools/auth0/handlers/themes.tests.js +++ b/test/tools/auth0/handlers/themes.tests.js @@ -322,6 +322,97 @@ describe('#themes handler', () => { expect(auth0.branding.themes.update.called).to.equal(false); expect(auth0.branding.themes.create.called).to.equal(false); }); + + describe('#themes dryRunChanges', () => { + it('should produce no create/delete when local theme lacks themeId (exported without --export_ids)', async () => { + const remoteTheme = mockTheme({ withThemeId: 'remote-theme-id' }); + const localTheme = omit(cloneDeep(remoteTheme), 'themeId'); + + const auth0 = { + branding: { + themes: { + getDefault: stub().returns(Promise.resolve(remoteTheme)), + }, + }, + }; + + const handler = new ThemesHandler({ client: auth0 }); + const changes = await handler.dryRunChanges({ themes: [localTheme] }); + + expect(changes.create).to.have.length(0); + expect(changes.del).to.have.length(0); + }); + + it('should produce no update when local theme lacks themeId and content is identical', async () => { + const remoteTheme = mockTheme({ withThemeId: 'remote-theme-id' }); + const localTheme = omit(cloneDeep(remoteTheme), 'themeId'); + + const auth0 = { + branding: { + themes: { + getDefault: stub().returns(Promise.resolve(remoteTheme)), + }, + }, + }; + + const handler = new ThemesHandler({ client: auth0 }); + const changes = await handler.dryRunChanges({ themes: [localTheme] }); + + expect(changes.update).to.have.length(0); + }); + + it('should propose update (not create/delete) when content differs and local theme lacks themeId', async () => { + const remoteTheme = mockTheme({ withThemeId: 'remote-theme-id' }); + const localTheme = { + ...omit(cloneDeep(remoteTheme), 'themeId'), + colors: { ...remoteTheme.colors, primary_button: '#aabbcc' }, + }; + + const auth0 = { + branding: { + themes: { + getDefault: stub().returns(Promise.resolve(remoteTheme)), + }, + }, + }; + + const handler = new ThemesHandler({ client: auth0 }); + const changes = await handler.dryRunChanges({ themes: [localTheme] }); + + expect(changes.create).to.have.length(0); + expect(changes.del).to.have.length(0); + expect(changes.update).to.have.length(1); + }); + + it('should produce no changes when local theme already has themeId and content matches', async () => { + const remoteTheme = mockTheme({ withThemeId: 'remote-theme-id' }); + + const auth0 = { + branding: { + themes: { + getDefault: stub().returns(Promise.resolve(remoteTheme)), + }, + }, + }; + + const handler = new ThemesHandler({ client: auth0 }); + const changes = await handler.dryRunChanges({ themes: [cloneDeep(remoteTheme)] }); + + expect(changes.create).to.have.length(0); + expect(changes.del).to.have.length(0); + expect(changes.update).to.have.length(0); + }); + + it('should return empty changes when themes is not present in assets', async () => { + const handler = new ThemesHandler({ client: {} }); + const changes = await handler.dryRunChanges({}); + + expect(changes.create).to.have.length(0); + expect(changes.del).to.have.length(0); + expect(changes.update).to.have.length(0); + expect(changes.conflicts).to.have.length(0); + }); + }); }); describe('#themes keyword preservation', () => {