Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/tools/auth0/handlers/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
)
)
);
Expand Down
29 changes: 22 additions & 7 deletions src/tools/auth0/handlers/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -152,14 +153,28 @@ export default class RulesHandler extends DefaultHandler {
}

async dryRunChanges(assets: Assets): Promise<CalculatedChanges> {
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<void> {
Expand Down
32 changes: 31 additions & 1 deletion src/tools/auth0/handlers/themes.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -437,6 +438,35 @@ export default class ThemesHandler extends DefaultHandler {
return theme.displayName || JSON.stringify(theme);
}

async dryRunChanges(assets: Assets): Promise<CalculatedChanges> {
const { themes } = assets;

if (!themes) {
return { del: [], create: [], conflicts: [], update: [] };
}

const existing = await this.getType();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing: getType() returns null when no-code isn't enabled (the 400 case in getThemes), and passing that straight into calculateDryRunChanges will blow up on [null]. The rules fix in this same PR guards it - could we do the same here?

const existing = await this.getType();
if (existing === null) {
  return { del: [], create: [], conflicts: [], update: [] };
}


// 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<Theme[] | null> {
if (!this.existing) {
this.existing = await this.getThemes();
Expand Down
123 changes: 121 additions & 2 deletions test/tools/auth0/handlers/dryRun.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down
91 changes: 91 additions & 0 deletions test/tools/auth0/handlers/themes.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down