Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DXCDT-376: Preserve keywords function #745

Merged
merged 19 commits into from
Feb 21, 2023
Merged
Show file tree
Hide file tree
Changes from 18 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
118 changes: 110 additions & 8 deletions src/keywordPreservation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { get as getByDotNotation } from 'dot-prop';
import { get as getByDotNotation, set as setByDotNotation } from 'dot-prop';
import { keywordReplace } from './tools/utils';
import { KeywordMappings } from './types';
import { keywordReplaceArrayRegExp, keywordReplaceStringRegExp } from './tools/utils';
import { cloneDeep } from 'lodash';

/*
RFC for Keyword Preservation: https://github.com/auth0/auth0-deploy-cli/issues/688
Original Github Issue: https://github.com/auth0/auth0-deploy-cli/issues/328
*/

export const shouldFieldBePreserved = (
string: string,
Expand All @@ -15,9 +22,9 @@ export const shouldFieldBePreserved = (
};

export const getPreservableFieldsFromAssets = (
asset: any,
address: string,
keywordMappings: KeywordMappings
asset: object,
keywordMappings: KeywordMappings,
address = ''
Comment on lines 24 to +27
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Re-ordering the arguments, particularly making keywordMappings second to be more consistent with the other functions of that file.

): string[] => {
if (typeof asset === 'string') {
if (shouldFieldBePreserved(asset, keywordMappings)) {
Expand All @@ -39,8 +46,8 @@ export const getPreservableFieldsFromAssets = (

return getPreservableFieldsFromAssets(
arrayItem,
`${address}${shouldRenderDot ? '.' : ''}[name=${arrayItem.name}]`,
keywordMappings
keywordMappings,
`${address}${shouldRenderDot ? '.' : ''}[name=${arrayItem.name}]`
);
})
.flat();
Expand All @@ -54,8 +61,8 @@ export const getPreservableFieldsFromAssets = (

return getPreservableFieldsFromAssets(
value,
`${address}${shouldRenderDot ? '.' : ''}${key}`,
keywordMappings
keywordMappings,
`${address}${shouldRenderDot ? '.' : ''}${key}`
);
})
.flat();
Expand Down Expand Up @@ -102,3 +109,98 @@ export const getAssetsValueByAddress = (address: string, assets: any): any => {
getByDotNotation(assets, directions[0])
);
};

// convertAddressToDotNotation will convert the proprietary address into conventional
// JS object notation. Performing this conversion simplifies the process
// of updating a specific property for a given asset tree using the dot-prop library
export const convertAddressToDotNotation = (
assets: any,
address: string,
finalAddressTrail = ''
): string => {
const directions = address.split('.');

if (directions[0] === '') return finalAddressTrail;

if (directions[0].charAt(0) === '[') {
const identifier = directions[0].substring(1, directions[0].length - 1).split('=')[0];
const identifierValue = directions[0].substring(1, directions[0].length - 1).split('=')[1];

let targetIndex = -1;

assets.forEach((item: any, index: number) => {
if (item[identifier] === identifierValue) {
targetIndex = index;
}
});

if (targetIndex === -1)
throw new Error(`Cannot find ${directions[0]} in ${JSON.stringify(assets)}`);

return convertAddressToDotNotation(
assets[targetIndex],
directions.slice(1).join('.'),
`${finalAddressTrail}.${targetIndex}`
);
}

return convertAddressToDotNotation(
getByDotNotation(assets, directions[0]),
directions.slice(1).join('.'),
finalAddressTrail === '' ? directions[0] : `${finalAddressTrail}.${directions[0]}`
);
};

export const updateAssetsByAddress = (
assets: object,
address: string,
newValue: string
): object => {
const dotNotationAddress = convertAddressToDotNotation(assets, address);

const doesPropertyExist = getByDotNotation(assets, dotNotationAddress) !== undefined;

if (!doesPropertyExist) {
throw new Error(`cannot update assets by address: ${address} because it does not exist.`);
}

setByDotNotation(assets, dotNotationAddress, newValue);
return assets;
};

// preserveKeywords is the function that ultimately gets executed during export
// to attempt to preserve keywords (ex: ##KEYWORD##) in local configuration files
// from getting overwritten by remote values during export.
export const preserveKeywords = (
localAssets: object,
remoteAssets: object,
keywordMappings: KeywordMappings
): object => {
const addresses = getPreservableFieldsFromAssets(localAssets, keywordMappings, '');

let updatedRemoteAssets = cloneDeep(remoteAssets);

addresses.forEach((address) => {
const localValue = getAssetsValueByAddress(address, localAssets);
const remoteValue = getAssetsValueByAddress(address, remoteAssets);

const localValueWithReplacement = keywordReplace(localValue, keywordMappings);

const localAndRemoteValuesAreEqual = (() => {
if (typeof remoteValue === 'string') {
return localValueWithReplacement === remoteValue;
}
//TODO: Account for non-string replacements via @@ syntax
})();

if (!localAndRemoteValuesAreEqual) {
console.warn(
`WARNING! The remote value with address of ${address} has value of "${remoteValue}" but will be preserved with "${localValueWithReplacement}" due to keyword preservation.`
);
}

updatedRemoteAssets = updateAssetsByAddress(updatedRemoteAssets, address, localValue);
});

return updatedRemoteAssets;
};
199 changes: 198 additions & 1 deletion test/keywordPreservation.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { expect } from 'chai';
import { get as getDotNotation } from 'dot-prop';
import {
shouldFieldBePreserved,
getPreservableFieldsFromAssets,
getAssetsValueByAddress,
convertAddressToDotNotation,
updateAssetsByAddress,
preserveKeywords,
} from '../src/keywordPreservation';
import { cloneDeep } from 'lodash';

describe('#Keyword Preservation', () => {
describe('shouldFieldBePreserved', () => {
Expand Down Expand Up @@ -79,7 +84,6 @@ describe('#Keyword Preservation', () => {
nullField: null,
undefinedField: undefined,
},
'',
{
KEYWORD: 'Travel0',
ARRAY_REPLACE_KEYWORD: ['this value', 'that value'],
Expand Down Expand Up @@ -148,3 +152,196 @@ describe('getAssetsValueByAddress', () => {
);
});
});

describe('convertAddressToDotNotation', () => {
const mockAssets = {
tenant: {
friendly_name: 'Friendly Tenant Name',
},
actions: [
{
name: 'action-1',
code: "window.alert('Foo')",
},
{
name: 'action-2',
nestedProperty: {
array: [
{
name: 'foo',
},
{
name: 'bar',
arrayProperty: 'baz',
},
],
},
},
],
};

it('should convert proprietary address to conventional JS object notation (aka "dot notation")', () => {
expect(convertAddressToDotNotation(mockAssets, 'tenant.friendly_name')).to.equal(
'tenant.friendly_name'
);
expect(getDotNotation(mockAssets, 'tenant.friendly_name')).to.equal(
mockAssets.tenant.friendly_name
);

expect(convertAddressToDotNotation(mockAssets, 'actions.[name=action-1].code')).to.equal(
'actions.0.code'
);
expect(getDotNotation(mockAssets, 'actions.0.code')).to.equal(mockAssets.actions[0].code);

expect(
convertAddressToDotNotation(
mockAssets,
'actions.[name=action-2].nestedProperty.array.[name=bar].arrayProperty'
)
).to.equal('actions.1.nestedProperty.array.1.arrayProperty');

expect(getDotNotation(mockAssets, 'actions.1.nestedProperty.array.1.arrayProperty')).to.equal(
mockAssets.actions[1].nestedProperty?.array[1].arrayProperty
);
});

it('should throw if provided address is invalid', () => {
expect(() =>
convertAddressToDotNotation(mockAssets, 'actions.[name=this-action-does-not-exist].code')
).to.throw(
`Cannot find [name=this-action-does-not-exist] in [{"name":"action-1","code":"window.alert('Foo')"},{"name":"action-2","nestedProperty":{"array":[{"name":"foo"},{"name":"bar","arrayProperty":"baz"}]}}]`
);
});
});

describe('updateAssetsByAddress', () => {
const mockAssetTree = {
tenant: {
display_name: 'This is my tenant display name',
},
clients: [
{
name: 'client-1',
display_name: 'Some Display Name',
},
{
name: 'client-2',
display_name: 'This is the target value',
},
{
name: 'client-3',
connections: [
{
connection_name: 'connection-1',
display_name: 'My connection display name',
},
],
},
],
};
it('should update an specific asset field for a provided address', () => {
expect(
updateAssetsByAddress(
mockAssetTree,
'clients.[name=client-3].connections.[connection_name=connection-1].display_name',
'New connection display name'
)
).to.deep.equal(
(() => {
const newAssets = mockAssetTree;
//@ts-ignore because we know this value is defined
newAssets.clients[2].connections[0].display_name = 'New connection display name';
return newAssets;
})()
);

expect(
updateAssetsByAddress(mockAssetTree, 'tenant.display_name', 'This is the new display name')
).to.deep.equal(
(() => {
const newAssets = mockAssetTree;
newAssets.tenant.display_name = 'This is the new display name';
return newAssets;
})()
);
});

it('should throw errors if invalid addresses provided', () => {
expect(() =>
updateAssetsByAddress(mockAssetTree, 'clients.[name=this-client-does-not-exist]', '_')
).to.throw();

expect(() =>
updateAssetsByAddress(mockAssetTree, 'tenant.this_property_does_not_exist', '_')
).to.throw(
'cannot update assets by address: tenant.this_property_does_not_exist because it does not exist.'
);
});
});

describe('preserveKeywords', () => {
const mockLocalAssets = {
tenant: {
display_name: 'The ##COMPANY_NAME## Tenant',
allowed_logout_urls: '@@ALLOWED_LOGOUT_URLS@@',
},
roles: null,
hooks: undefined,
actions: [
{
name: 'action-1',
display_name: '##ENV## Action 1',
},
{
name: 'action-2',
display_name: "This action won't exist on remote, will be deleted",
},
],
};

const mockRemoteAssets = {
tenant: {
display_name: 'The Travel0 Tenant',
allowed_logout_urls: ['localhost:3000/logout', 'https://travel0.com/logout'],
},
prompts: {
universal_login_enabled: true,
customText: {},
},
pages: undefined, //TODO: test these cases more thoroughly
rules: null, //TODO: test these cases more thoroughly
actions: [
{
name: 'action-1',
display_name: 'Production Action 1',
},
{
name: 'action-3',
display_name: 'This action exists on remote but not local',
},
],
};

it('should preserve keywords when they correlate to keyword mappings', () => {
const preservedAssets = preserveKeywords(mockLocalAssets, mockRemoteAssets, {
COMPANY_NAME: 'Travel0',
ALLOWED_LOGOUT_URLS: ['localhost:3000/logout', 'https://travel0.com/logout'],
ENV: 'Production',
});

expect(preservedAssets).to.deep.equal(
(() => {
const expected = cloneDeep(mockRemoteAssets);
//@ts-ignore
expected.tenant = mockLocalAssets.tenant;
expected.actions[0].display_name = '##ENV## Action 1';
return expected;
})()
);
});

it('should not preserve keywords when no keyword mappings', () => {
const preservedAssets = preserveKeywords(mockLocalAssets, mockRemoteAssets, {});
expect(preservedAssets).to.deep.equal(mockRemoteAssets);
});
});