@contrail/cli 3.5.4 → 3.5.5-alpha.270bb0b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +50 -1
  2. package/dist/api/feature-flags.d.ts +3 -0
  3. package/dist/api/feature-flags.js +29 -0
  4. package/dist/api/users.d.ts +1 -0
  5. package/dist/api/users.js +10 -1
  6. package/dist/commands/feature-flags/export.d.ts +19 -0
  7. package/dist/commands/feature-flags/export.js +66 -0
  8. package/dist/commands/feature-flags/export.spec.d.ts +1 -0
  9. package/dist/commands/feature-flags/export.spec.js +109 -0
  10. package/dist/commands/feature-flags/import.d.ts +42 -0
  11. package/dist/commands/feature-flags/import.js +191 -0
  12. package/dist/commands/feature-flags/import.spec.d.ts +1 -0
  13. package/dist/commands/feature-flags/import.spec.js +194 -0
  14. package/dist/commands/instance/export.d.ts +9 -0
  15. package/dist/commands/instance/export.js +18 -3
  16. package/dist/commands/instance/export.spec.d.ts +1 -0
  17. package/dist/commands/instance/export.spec.js +32 -0
  18. package/dist/commands/instance/import.d.ts +3 -0
  19. package/dist/commands/instance/import.js +5 -0
  20. package/dist/commands/instance/import.spec.js +4 -0
  21. package/dist/helpers/app/app-config-validators.d.ts +5 -0
  22. package/dist/helpers/app/app-config-validators.js +76 -3
  23. package/dist/helpers/app/app-config-validators.spec.js +35 -0
  24. package/dist/helpers/app/extensions-deployer.d.ts +10 -2
  25. package/dist/helpers/app/extensions-deployer.js +31 -37
  26. package/dist/helpers/app/extensions-deployer.spec.d.ts +1 -0
  27. package/dist/helpers/app/extensions-deployer.spec.js +29 -0
  28. package/dist/interfaces.d.ts +26 -0
  29. package/oclif.manifest.json +111 -1
  30. package/package.json +4 -1
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const fs_1 = tslib_1.__importDefault(require("fs"));
5
+ const import_1 = require("./import");
6
+ const file_helpers_1 = require("../../helpers/file-helpers");
7
+ const feature_flags_1 = require("../../api/feature-flags");
8
+ const users_1 = require("../../api/users");
9
+ const logger_1 = tslib_1.__importDefault(require("../../logger"));
10
+ jest.mock('../../helpers/file-helpers');
11
+ jest.mock('../../api/feature-flags');
12
+ jest.mock('../../api/users');
13
+ const mocked = (fn) => fn;
14
+ describe('assertCanManageFeatureFlags', () => {
15
+ beforeEach(() => {
16
+ jest.clearAllMocks();
17
+ });
18
+ it.each(['PLATFORM_ADMIN', 'PLATFORM_MANAGER'])('allows a %s', async (role) => {
19
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockResolvedValue({ platformRoles: [role] });
20
+ await expect((0, import_1.assertCanManageFeatureFlags)()).resolves.toBeUndefined();
21
+ });
22
+ it('accepts platformRoles serialized as a JSON string', async () => {
23
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockResolvedValue({
24
+ platformRoles: '["PLATFORM_MANAGER"]',
25
+ });
26
+ await expect((0, import_1.assertCanManageFeatureFlags)()).resolves.toBeUndefined();
27
+ });
28
+ it('rejects a user with no platform role', async () => {
29
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockResolvedValue({ platformRoles: [] });
30
+ await expect((0, import_1.assertCanManageFeatureFlags)()).rejects.toThrow('requires the PLATFORM_ADMIN or PLATFORM_MANAGER platform role');
31
+ });
32
+ it('rejects a user holding only some other platform role', async () => {
33
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockResolvedValue({
34
+ platformRoles: ['PLATFORM_USER'],
35
+ });
36
+ await expect((0, import_1.assertCanManageFeatureFlags)()).rejects.toThrow('Your roles: PLATFORM_USER.');
37
+ });
38
+ it('rejects when the roles could not be read', async () => {
39
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockRejectedValue(new Error('Forbidden resource'));
40
+ await expect((0, import_1.assertCanManageFeatureFlags)()).rejects.toThrow('Could not read your platform roles. Forbidden resource');
41
+ });
42
+ });
43
+ describe('uploadFeatureFlags', () => {
44
+ let existsSyncSpy;
45
+ beforeEach(() => {
46
+ jest.clearAllMocks();
47
+ existsSyncSpy = jest.spyOn(fs_1.default, 'existsSync').mockReturnValue(true);
48
+ mocked(feature_flags_1.createFeatureFlag).mockResolvedValue({});
49
+ mocked(feature_flags_1.deleteFeatureFlag).mockResolvedValue({});
50
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([]);
51
+ });
52
+ afterEach(() => {
53
+ existsSyncSpy.mockRestore();
54
+ });
55
+ it('skips when the export has no feature-flags.yml', async () => {
56
+ existsSyncSpy.mockReturnValue(false);
57
+ await (0, import_1.uploadFeatureFlags)('export-dir');
58
+ expect(mocked(feature_flags_1.getFeatureFlags)).not.toHaveBeenCalled();
59
+ expect(mocked(feature_flags_1.createFeatureFlag)).not.toHaveBeenCalled();
60
+ });
61
+ it('skips an export that lists no flags instead of disabling everything in the target org', async () => {
62
+ mocked(file_helpers_1.loadData).mockReturnValue([]);
63
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([
64
+ { id: '1', featureName: 'SHOWROOM' },
65
+ { id: '2', featureName: 'DEV_TABS' },
66
+ ]);
67
+ const infoSpy = jest.spyOn(logger_1.default, 'info').mockImplementation(() => undefined);
68
+ await (0, import_1.uploadFeatureFlags)('export-dir');
69
+ expect(mocked(feature_flags_1.deleteFeatureFlag)).not.toHaveBeenCalled();
70
+ expect(mocked(feature_flags_1.createFeatureFlag)).not.toHaveBeenCalled();
71
+ expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('lists no feature flags'));
72
+ infoSpy.mockRestore();
73
+ });
74
+ it('treats a blank feature-flags.yml the same as an empty list', async () => {
75
+ mocked(file_helpers_1.loadData).mockReturnValue(undefined);
76
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([{ id: '1', featureName: 'SHOWROOM' }]);
77
+ await (0, import_1.uploadFeatureFlags)('export-dir');
78
+ expect(mocked(feature_flags_1.getFeatureFlags)).not.toHaveBeenCalled();
79
+ expect(mocked(feature_flags_1.deleteFeatureFlag)).not.toHaveBeenCalled();
80
+ });
81
+ it('enables every flag from the export that the target org is missing', async () => {
82
+ mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }, { featureName: 'PROMOTE' }]);
83
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([{ id: '1', featureName: 'SHOWROOM' }]);
84
+ await (0, import_1.uploadFeatureFlags)('export-dir');
85
+ expect(mocked(feature_flags_1.createFeatureFlag)).toHaveBeenCalledTimes(1);
86
+ expect(mocked(feature_flags_1.createFeatureFlag)).toHaveBeenCalledWith('PROMOTE');
87
+ });
88
+ it('disables flags the target org has that the export does not, so the orgs match', async () => {
89
+ mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }]);
90
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([
91
+ { id: '1', featureName: 'SHOWROOM' },
92
+ { id: '2', featureName: 'DEV_TABS' },
93
+ ]);
94
+ await (0, import_1.uploadFeatureFlags)('export-dir');
95
+ expect(mocked(feature_flags_1.createFeatureFlag)).not.toHaveBeenCalled();
96
+ expect(mocked(feature_flags_1.deleteFeatureFlag)).toHaveBeenCalledTimes(1);
97
+ expect(mocked(feature_flags_1.deleteFeatureFlag)).toHaveBeenCalledWith('2');
98
+ });
99
+ it('summarizes what it removed, and why, at the end of the run', async () => {
100
+ const infoSpy = jest.spyOn(logger_1.default, 'info').mockImplementation(() => undefined);
101
+ mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }]);
102
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([
103
+ { id: '1', featureName: 'SHOWROOM' },
104
+ { id: '2', featureName: 'PROMOTE' },
105
+ { id: '3', featureName: 'DEV_TABS' },
106
+ ]);
107
+ await (0, import_1.uploadFeatureFlags)('export-dir');
108
+ const summary = infoSpy.mock.calls.map(([message]) => String(message)).at(-1);
109
+ expect(summary).toContain('2 flag(s) were enabled in this org but not in the source');
110
+ expect(summary).toContain('DEV_TABS, PROMOTE');
111
+ infoSpy.mockRestore();
112
+ });
113
+ it('does not print a removal summary when nothing was removed', async () => {
114
+ const infoSpy = jest.spyOn(logger_1.default, 'info').mockImplementation(() => undefined);
115
+ mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }]);
116
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([{ id: '1', featureName: 'SHOWROOM' }]);
117
+ await (0, import_1.uploadFeatureFlags)('export-dir');
118
+ const messages = infoSpy.mock.calls.map(([message]) => String(message));
119
+ expect(messages.some((message) => message.includes('so they were removed'))).toBe(false);
120
+ infoSpy.mockRestore();
121
+ });
122
+ it('accepts plain feature names and ignores entries with no featureName', async () => {
123
+ mocked(file_helpers_1.loadData).mockReturnValue(['SHOWROOM', { featureName: ' PROMOTE ' }, {}, null]);
124
+ await (0, import_1.uploadFeatureFlags)('export-dir');
125
+ expect(mocked(feature_flags_1.createFeatureFlag).mock.calls.map(([name]) => name)).toEqual([
126
+ 'SHOWROOM',
127
+ 'PROMOTE',
128
+ ]);
129
+ });
130
+ it('rejects when the file is not a list', async () => {
131
+ mocked(file_helpers_1.loadData).mockReturnValue({ featureName: 'SHOWROOM' });
132
+ await expect((0, import_1.uploadFeatureFlags)('export-dir')).rejects.toThrow('must contain a YAML list of feature flags');
133
+ });
134
+ it('applies what it can and then rejects, rather than reporting success', async () => {
135
+ mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }, { featureName: 'PROMOTE' }]);
136
+ mocked(feature_flags_1.createFeatureFlag).mockImplementation(async (featureName) => {
137
+ if (featureName === 'SHOWROOM') {
138
+ throw new Error('Forbidden resource');
139
+ }
140
+ return {};
141
+ });
142
+ await expect((0, import_1.uploadFeatureFlags)('export-dir')).rejects.toThrow('could not enable SHOWROOM: Forbidden resource');
143
+ expect(mocked(feature_flags_1.createFeatureFlag)).toHaveBeenCalledWith('PROMOTE');
144
+ });
145
+ });
146
+ describe('uploadFeatureFlagsIfPermitted', () => {
147
+ let existsSyncSpy;
148
+ let infoSpy;
149
+ beforeEach(() => {
150
+ jest.clearAllMocks();
151
+ existsSyncSpy = jest.spyOn(fs_1.default, 'existsSync').mockReturnValue(true);
152
+ infoSpy = jest.spyOn(logger_1.default, 'info').mockImplementation(() => undefined);
153
+ mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }]);
154
+ mocked(feature_flags_1.createFeatureFlag).mockResolvedValue({});
155
+ mocked(feature_flags_1.deleteFeatureFlag).mockResolvedValue({});
156
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([]);
157
+ });
158
+ afterEach(() => {
159
+ existsSyncSpy.mockRestore();
160
+ infoSpy.mockRestore();
161
+ });
162
+ const messages = () => infoSpy.mock.calls.map(([message]) => String(message)).join('\n');
163
+ it('applies the flags when the user has a platform role', async () => {
164
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockResolvedValue({
165
+ platformRoles: ['PLATFORM_MANAGER'],
166
+ });
167
+ await (0, import_1.uploadFeatureFlagsIfPermitted)('export-dir');
168
+ expect(mocked(feature_flags_1.createFeatureFlag)).toHaveBeenCalledWith('SHOWROOM');
169
+ });
170
+ it('skips with a log instead of failing when the user has no platform role', async () => {
171
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockResolvedValue({ platformRoles: [] });
172
+ await expect((0, import_1.uploadFeatureFlagsIfPermitted)('export-dir')).resolves.toBeUndefined();
173
+ expect(mocked(feature_flags_1.getFeatureFlags)).not.toHaveBeenCalled();
174
+ expect(mocked(feature_flags_1.createFeatureFlag)).not.toHaveBeenCalled();
175
+ expect(mocked(feature_flags_1.deleteFeatureFlag)).not.toHaveBeenCalled();
176
+ expect(messages()).toContain('Skipping feature flags');
177
+ expect(messages()).toContain('PLATFORM_ADMIN or PLATFORM_MANAGER');
178
+ expect(messages()).toContain('contrail feature-flags import -d export-dir');
179
+ });
180
+ it('skips with a log when the platform roles cannot be read', async () => {
181
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockRejectedValue(new Error('Forbidden resource'));
182
+ await expect((0, import_1.uploadFeatureFlagsIfPermitted)('export-dir')).resolves.toBeUndefined();
183
+ expect(mocked(feature_flags_1.createFeatureFlag)).not.toHaveBeenCalled();
184
+ expect(messages()).toContain('Skipping feature flags');
185
+ expect(messages()).toContain('Forbidden resource');
186
+ });
187
+ it('still propagates a failure from the upload itself', async () => {
188
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockResolvedValue({
189
+ platformRoles: ['PLATFORM_ADMIN'],
190
+ });
191
+ mocked(feature_flags_1.createFeatureFlag).mockRejectedValue(new Error('boom'));
192
+ await expect((0, import_1.uploadFeatureFlagsIfPermitted)('export-dir')).rejects.toThrow('could not enable SHOWROOM: boom');
193
+ });
194
+ });
@@ -10,3 +10,12 @@ export default class ExportInstance extends BaseCommand {
10
10
  };
11
11
  run(): Promise<void>;
12
12
  }
13
+ /**
14
+ * Exports everything `contrail instance import` loads back: types, users, groups and the
15
+ * org's enabled feature flags. Org config (branding, sorting, admin-console settings) is
16
+ * written by getAllTypes, so it is not repeated here.
17
+ *
18
+ * Exporting feature flags is a plain read, so it works for any user — only the import side
19
+ * needs a platform role.
20
+ */
21
+ export declare function exportInstance(outDir: string, overwrite: boolean): Promise<void>;
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.exportInstance = void 0;
3
4
  const tslib_1 = require("tslib");
4
5
  const path = tslib_1.__importStar(require("path"));
5
6
  const core_1 = require("@oclif/core");
6
7
  const getAll_1 = require("../types/getAll");
7
8
  const export_1 = require("../users/export");
8
9
  const export_2 = require("../groups/export");
10
+ const export_3 = require("../feature-flags/export");
9
11
  const basecommand_1 = require("../../basecommand");
10
12
  class ExportInstance extends basecommand_1.BaseCommand {
11
13
  static description = 'Exports an data of an instance including types and users';
@@ -29,9 +31,22 @@ class ExportInstance extends basecommand_1.BaseCommand {
29
31
  }),
30
32
  };
31
33
  async run() {
32
- await (0, getAll_1.getAll)(this.flags.outDir, this.flags.overwrite);
33
- await (0, export_1.exportUsers)(path.join(this.flags.outDir, 'users.yml'), this.flags.overwrite);
34
- await (0, export_2.exportGroups)(path.join(this.flags.outDir, 'groups.yml'), this.flags.overwrite);
34
+ await exportInstance(this.flags.outDir, this.flags.overwrite);
35
35
  }
36
36
  }
37
37
  exports.default = ExportInstance;
38
+ /**
39
+ * Exports everything `contrail instance import` loads back: types, users, groups and the
40
+ * org's enabled feature flags. Org config (branding, sorting, admin-console settings) is
41
+ * written by getAllTypes, so it is not repeated here.
42
+ *
43
+ * Exporting feature flags is a plain read, so it works for any user — only the import side
44
+ * needs a platform role.
45
+ */
46
+ async function exportInstance(outDir, overwrite) {
47
+ await (0, getAll_1.getAll)(outDir, overwrite);
48
+ await (0, export_1.exportUsers)(path.join(outDir, 'users.yml'), overwrite);
49
+ await (0, export_2.exportGroups)(path.join(outDir, 'groups.yml'), overwrite);
50
+ await (0, export_3.exportFeatureFlags)(outDir, overwrite);
51
+ }
52
+ exports.exportInstance = exportInstance;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const export_1 = require("./export");
4
+ const getAll_1 = require("../types/getAll");
5
+ const export_2 = require("../users/export");
6
+ const export_3 = require("../groups/export");
7
+ const export_4 = require("../feature-flags/export");
8
+ jest.mock('../types/getAll');
9
+ jest.mock('../users/export');
10
+ jest.mock('../groups/export');
11
+ jest.mock('../feature-flags/export');
12
+ const mocked = (fn) => fn;
13
+ const STEPS = [getAll_1.getAll, export_2.exportUsers, export_3.exportGroups, export_4.exportFeatureFlags];
14
+ describe('exportInstance', () => {
15
+ beforeEach(() => {
16
+ jest.clearAllMocks();
17
+ for (const fn of STEPS) {
18
+ mocked(fn).mockResolvedValue(undefined);
19
+ }
20
+ });
21
+ it('exports everything the import loads back, including feature flags', async () => {
22
+ await (0, export_1.exportInstance)('out-dir', true);
23
+ expect(mocked(getAll_1.getAll)).toHaveBeenCalledWith('out-dir', true);
24
+ expect(mocked(export_2.exportUsers)).toHaveBeenCalledWith('out-dir/users.yml', true);
25
+ expect(mocked(export_3.exportGroups)).toHaveBeenCalledWith('out-dir/groups.yml', true);
26
+ expect(mocked(export_4.exportFeatureFlags)).toHaveBeenCalledWith('out-dir', true);
27
+ });
28
+ it('passes the overwrite flag through to the feature flags export', async () => {
29
+ await (0, export_1.exportInstance)('out-dir', false);
30
+ expect(mocked(export_4.exportFeatureFlags)).toHaveBeenCalledWith('out-dir', false);
31
+ });
32
+ });
@@ -17,6 +17,9 @@ export default class ImportInstance extends BaseCommand {
17
17
  * 4. type overrides — after policies, because they reference policies, rule sets and option
18
18
  * set hierarchies by slug and the API rejects the whole upload if any of them is missing
19
19
  * 5. org config
20
+ * 6. feature flags — skipped with a log message when the user has no platform role,
21
+ * since applying them needs PLATFORM_ADMIN/PLATFORM_MANAGER but the rest of the
22
+ * import does not, and org admins are expected to run this command
20
23
  *
21
24
  * Fails (rejects) if the type overrides could not be applied, so the import does not
22
25
  * report success while the org is missing customizations carried by overrides
@@ -11,6 +11,7 @@ const uploadTypePropertyPolicies_1 = require("../types/uploadTypePropertyPolicie
11
11
  const uploadTypePolicies_1 = require("../types/uploadTypePolicies");
12
12
  const uploadTypeOverrides_1 = require("../types/uploadTypeOverrides");
13
13
  const import_3 = require("../org-config/import");
14
+ const import_4 = require("../feature-flags/import");
14
15
  const basecommand_1 = require("../../basecommand");
15
16
  class ImportInstance extends basecommand_1.BaseCommand {
16
17
  static description = 'Import the data to an instance, including types and users';
@@ -40,6 +41,9 @@ exports.default = ImportInstance;
40
41
  * 4. type overrides — after policies, because they reference policies, rule sets and option
41
42
  * set hierarchies by slug and the API rejects the whole upload if any of them is missing
42
43
  * 5. org config
44
+ * 6. feature flags — skipped with a log message when the user has no platform role,
45
+ * since applying them needs PLATFORM_ADMIN/PLATFORM_MANAGER but the rest of the
46
+ * import does not, and org admins are expected to run this command
43
47
  *
44
48
  * Fails (rejects) if the type overrides could not be applied, so the import does not
45
49
  * report success while the org is missing customizations carried by overrides
@@ -53,5 +57,6 @@ async function importInstance(dir) {
53
57
  await (0, uploadTypePolicies_1.uploadTypePolicies)(dir);
54
58
  await (0, uploadTypeOverrides_1.uploadTypeOverrides)(dir);
55
59
  await (0, import_3.uploadOrgConfig)(dir);
60
+ await (0, import_4.uploadFeatureFlagsIfPermitted)(dir);
56
61
  }
57
62
  exports.importInstance = importInstance;
@@ -8,6 +8,7 @@ const uploadTypePropertyPolicies_1 = require("../types/uploadTypePropertyPolicie
8
8
  const uploadTypePolicies_1 = require("../types/uploadTypePolicies");
9
9
  const uploadTypeOverrides_1 = require("../types/uploadTypeOverrides");
10
10
  const import_4 = require("../org-config/import");
11
+ const import_5 = require("../feature-flags/import");
11
12
  jest.mock('../types/loadAll');
12
13
  jest.mock('../users/import');
13
14
  jest.mock('../groups/import');
@@ -15,6 +16,7 @@ jest.mock('../types/uploadTypePropertyPolicies');
15
16
  jest.mock('../types/uploadTypePolicies');
16
17
  jest.mock('../types/uploadTypeOverrides');
17
18
  jest.mock('../org-config/import');
19
+ jest.mock('../feature-flags/import');
18
20
  const mocked = (fn) => fn;
19
21
  const STEPS = [
20
22
  loadAll_1.loadAll,
@@ -24,6 +26,7 @@ const STEPS = [
24
26
  uploadTypePolicies_1.uploadTypePolicies,
25
27
  uploadTypeOverrides_1.uploadTypeOverrides,
26
28
  import_4.uploadOrgConfig,
29
+ import_5.uploadFeatureFlagsIfPermitted,
27
30
  ];
28
31
  describe('importInstance', () => {
29
32
  beforeEach(() => {
@@ -37,6 +40,7 @@ describe('importInstance', () => {
37
40
  expect(mocked(loadAll_1.loadAll)).toHaveBeenCalledWith('export-dir', true, false);
38
41
  expect(mocked(uploadTypeOverrides_1.uploadTypeOverrides)).toHaveBeenCalledWith('export-dir');
39
42
  expect(mocked(import_4.uploadOrgConfig)).toHaveBeenCalledWith('export-dir');
43
+ expect(mocked(import_5.uploadFeatureFlagsIfPermitted)).toHaveBeenCalledWith('export-dir');
40
44
  const order = STEPS.map((fn) => mocked(fn).mock.invocationCallOrder[0]);
41
45
  // strictly increasing => called in exactly this order
42
46
  for (let i = 1; i < order.length; i++) {
@@ -15,7 +15,12 @@ export declare const ERROR_MESSAGES: {
15
15
  ignoredAccessGrants: string;
16
16
  invalidPermission: (permission: string) => string;
17
17
  invalidAccessGrants: (error: string) => string;
18
+ invalidExtensionIcon: (identifier: string) => string;
19
+ invalidAppliesTo: (identifier: string, problem: string) => string;
18
20
  };
21
+ export declare function isValidExtensionIcon(icon: unknown): boolean;
22
+ /** Icons and appliesTo are checked here, since the apps service stores what it is given. */
23
+ export declare function validateExtensionPlacement(appDeclaration: AppDeclaration): AppConfigValidationError;
19
24
  export declare function validateAppConfig(appConfig: AppDeclaration): void;
20
25
  export declare function appVersionValidator(appDeclaration: AppDeclaration): AppConfigValidationError;
21
26
  export declare function visibilityValidator(appDeclaration: AppDeclaration): AppConfigValidationError;
@@ -1,10 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.validateAccessGrants = exports.visibilityValidator = exports.appVersionValidator = exports.validateAppConfig = exports.ERROR_MESSAGES = void 0;
3
+ exports.validateAccessGrants = exports.visibilityValidator = exports.appVersionValidator = exports.validateAppConfig = exports.validateExtensionPlacement = exports.isValidExtensionIcon = exports.ERROR_MESSAGES = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const interfaces_1 = require("../../interfaces");
6
6
  const logger_1 = tslib_1.__importDefault(require("../../logger"));
7
- const VALIDATORS = [appVersionValidator, visibilityValidator, validateAccessGrants];
7
+ const VALIDATORS = [
8
+ appVersionValidator,
9
+ visibilityValidator,
10
+ validateAccessGrants,
11
+ validateExtensionPlacement,
12
+ ];
8
13
  exports.ERROR_MESSAGES = {
9
14
  missingVersionField: 'Missing "version" field". Semantic App Version looks like "1.0.0". Use "contrail app version [patch|minor|major]" to update the app version.',
10
15
  invalidVersionField: '"version" field is not a string. Expecting a version like "1.0.0". Manually update the "version" field to be a string in the app.yml file.',
@@ -17,7 +22,75 @@ exports.ERROR_MESSAGES = {
17
22
  ignoredAccessGrants: 'App is set to public. accessGrants will be ignored.',
18
23
  invalidPermission: (permission) => `Invalid permission found in one of the accessGrants objects: ${permission}.`,
19
24
  invalidAccessGrants: (error) => `Invalid accessGrants. ${error}`,
25
+ invalidExtensionIcon: (identifier) => `Extension ${identifier}: icon must be a default icon name (like "line-builder"), { material: <icon name> } or { url: <SVG path or http(s) URL> }.`,
26
+ invalidAppliesTo: (identifier, problem) => `Extension ${identifier}: appliesTo ${problem}.`,
20
27
  };
28
+ const STRING_LIST_FIELDS = ['typePaths', 'excludeTypePaths', 'roles'];
29
+ const APPLIES_TO_FIELDS = [...STRING_LIST_FIELDS, 'minSelected', 'maxSelected', 'label'];
30
+ function isValidExtensionIcon(icon) {
31
+ if (icon === undefined || icon === null) {
32
+ return true;
33
+ }
34
+ if (typeof icon === 'string') {
35
+ return /^[a-z0-9-]+$/.test(icon);
36
+ }
37
+ if (typeof icon !== 'object' || Array.isArray(icon) || Object.keys(icon).length !== 1) {
38
+ return false;
39
+ }
40
+ const { material, url } = icon;
41
+ if ('material' in icon) {
42
+ return typeof material === 'string' && /^[a-z0-9_]+$/.test(material);
43
+ }
44
+ if ('url' in icon) {
45
+ return typeof url === 'string' && url.length > 0 && !/^(javascript|data):/i.test(url.trim());
46
+ }
47
+ return false;
48
+ }
49
+ exports.isValidExtensionIcon = isValidExtensionIcon;
50
+ function appliesToProblem(appliesTo) {
51
+ if (typeof appliesTo !== 'object' || appliesTo === null || Array.isArray(appliesTo)) {
52
+ return 'must be an object';
53
+ }
54
+ const fields = appliesTo;
55
+ const unknownField = Object.keys(fields).find((field) => !APPLIES_TO_FIELDS.includes(field));
56
+ if (unknownField) {
57
+ return `has an unknown field "${unknownField}" (use ${APPLIES_TO_FIELDS.join(', ')})`;
58
+ }
59
+ for (const field of STRING_LIST_FIELDS) {
60
+ const value = fields[field];
61
+ if (value !== undefined &&
62
+ (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string'))) {
63
+ return `${field} must be a list of strings`;
64
+ }
65
+ }
66
+ for (const [field, min] of [
67
+ ['minSelected', 0],
68
+ ['maxSelected', 1],
69
+ ]) {
70
+ const value = fields[field];
71
+ if (value !== undefined && (!Number.isInteger(value) || value < min)) {
72
+ return `${field} must be a whole number of at least ${min}`;
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+ /** Icons and appliesTo are checked here, since the apps service stores what it is given. */
78
+ function validateExtensionPlacement(appDeclaration) {
79
+ const errors = [];
80
+ for (const extension of appDeclaration?.extensions ?? []) {
81
+ if (!isValidExtensionIcon(extension.icon)) {
82
+ errors.push(exports.ERROR_MESSAGES.invalidExtensionIcon(extension.identifier));
83
+ }
84
+ if (extension.appliesTo !== undefined) {
85
+ const problem = appliesToProblem(extension.appliesTo);
86
+ if (problem) {
87
+ errors.push(exports.ERROR_MESSAGES.invalidAppliesTo(extension.identifier, problem));
88
+ }
89
+ }
90
+ }
91
+ return { errors, warnings: [] };
92
+ }
93
+ exports.validateExtensionPlacement = validateExtensionPlacement;
21
94
  function validateAppConfig(appConfig) {
22
95
  const errors = [];
23
96
  const warnings = [];
@@ -30,7 +103,7 @@ function validateAppConfig(appConfig) {
30
103
  logger_1.default.warn(warnings);
31
104
  }
32
105
  if (errors.length) {
33
- throw new Error(`Invalid app config: \n ${errors.map((x) => x.error).join('\n')}`);
106
+ throw new Error(`Invalid app config: \n ${errors.map((x) => x?.error ?? x).join('\n')}`);
34
107
  }
35
108
  }
36
109
  exports.validateAppConfig = validateAppConfig;
@@ -308,3 +308,38 @@ describe('validateAccessGrants', () => {
308
308
  expect(validationResult).toEqual(expectedError);
309
309
  });
310
310
  });
311
+ describe('validateExtensionPlacement', () => {
312
+ const declarationWith = (extension) => ({
313
+ extensions: [{ identifier: 'line-builder', name: 'Line builder', ...extension }],
314
+ });
315
+ it('should accept default, Material and SVG icons, appliesTo, and extensions with neither', () => {
316
+ for (const icon of [
317
+ 'line-builder',
318
+ { material: 'grid_on' },
319
+ { url: 'assets/icons/line-builder.svg' },
320
+ undefined,
321
+ ]) {
322
+ expect((0, app_config_validators_1.validateExtensionPlacement)(declarationWith({ icon }))).toEqual(validResult);
323
+ }
324
+ expect((0, app_config_validators_1.validateExtensionPlacement)(declarationWith({
325
+ appliesTo: {
326
+ typePaths: ['custom-entity:silhouette', 'item:product'],
327
+ excludeTypePaths: ['item:material'],
328
+ roles: ['family'],
329
+ maxSelected: 20,
330
+ label: 'Designs or BOMs',
331
+ },
332
+ }))).toEqual(validResult);
333
+ });
334
+ it('should name the extension whose icon or appliesTo is wrong', () => {
335
+ expect((0, app_config_validators_1.validateExtensionPlacement)(declarationWith({ icon: { url: 'javascript:alert(1)' } })).errors).toEqual([app_config_validators_1.ERROR_MESSAGES.invalidExtensionIcon('line-builder')]);
336
+ expect((0, app_config_validators_1.validateExtensionPlacement)(declarationWith({ appliesTo: { typePath: 'item' } })).errors[0]).toContain('unknown field "typePath"');
337
+ expect((0, app_config_validators_1.validateExtensionPlacement)(declarationWith({ appliesTo: { maxSelected: 0 } })).errors[0]).toContain('maxSelected must be a whole number of at least 1');
338
+ });
339
+ it('should show the message when validation fails', () => {
340
+ expect(() => (0, app_config_validators_1.validateAppConfig)({
341
+ version: '1.0.0',
342
+ ...declarationWith({ icon: 'Bad Icon' }),
343
+ })).toThrow(app_config_validators_1.ERROR_MESSAGES.invalidExtensionIcon('line-builder'));
344
+ });
345
+ });
@@ -1,9 +1,17 @@
1
1
  import { AppExtensionDefinition } from '@contrail/entity-types';
2
- import { App, AppDeclaration, ExtensionDefinitionDeclaration } from '../../interfaces';
2
+ import { App, AppDeclaration, ExtensionAppliesToDeclaration, ExtensionDefinitionDeclaration, ExtensionIconDeclaration } from '../../interfaces';
3
3
  export declare function getExtensionDefinitionsForAppVersion(app: App): Promise<AppExtensionDefinition[]>;
4
+ /** Entity-types' definition plus placement, which the apps service stores as declared. */
5
+ export type PlacedExtensionDefinition = AppExtensionDefinition & {
6
+ icon?: ExtensionIconDeclaration;
7
+ appliesTo?: ExtensionAppliesToDeclaration;
8
+ group?: string;
9
+ };
10
+ /** The definition published for an app.yml extension, for a new version or an update alike. */
11
+ export declare function buildExtensionDefinition(extension: ExtensionDefinitionDeclaration, app: App, appName?: string): PlacedExtensionDefinition;
4
12
  export declare class ExtensionsDeployer {
5
13
  deployExtensions(appDeclaration: AppDeclaration, app: App): Promise<void>;
6
- deployExtension(extension: ExtensionDefinitionDeclaration, app: App, existingExtensions: Array<any>): Promise<void>;
14
+ deployExtension(extension: ExtensionDefinitionDeclaration, app: App, existingExtensions: Array<any>, appName?: string): Promise<void>;
7
15
  private createNewExtensionDefinition;
8
16
  private updateActionDefinition;
9
17
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ExtensionsDeployer = exports.getExtensionDefinitionsForAppVersion = void 0;
3
+ exports.ExtensionsDeployer = exports.buildExtensionDefinition = exports.getExtensionDefinitionsForAppVersion = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const sdk_1 = require("@contrail/sdk");
6
6
  const chalk_1 = tslib_1.__importDefault(require("chalk"));
@@ -13,6 +13,28 @@ async function getExtensionDefinitionsForAppVersion(app) {
13
13
  });
14
14
  }
15
15
  exports.getExtensionDefinitionsForAppVersion = getExtensionDefinitionsForAppVersion;
16
+ /** The definition published for an app.yml extension, for a new version or an update alike. */
17
+ function buildExtensionDefinition(extension, app, appName) {
18
+ return {
19
+ identifier: util_1.AppUtil.createExtensionIdentifier({
20
+ extensionIdentifier: extension?.identifier,
21
+ appIdentifier: app.identifier,
22
+ }),
23
+ name: extension.name,
24
+ appId: app.id,
25
+ appVersionId: app.latestAppVersionId,
26
+ extensionType: extension.extensionType,
27
+ userApps: extension.userApps,
28
+ /** @deprecated since entity-types v1.5.1, use display instead */
29
+ modalDimensions: extension.modalDimensions,
30
+ display: extension.display,
31
+ iframeUrl: extension.iframeUrl,
32
+ icon: extension.icon,
33
+ appliesTo: extension.appliesTo,
34
+ group: extension.group ?? appName,
35
+ };
36
+ }
37
+ exports.buildExtensionDefinition = buildExtensionDefinition;
16
38
  class ExtensionsDeployer {
17
39
  async deployExtensions(appDeclaration, app) {
18
40
  // FETCH ALL EXTENSIONS FROM THE API.
@@ -26,11 +48,11 @@ class ExtensionsDeployer {
26
48
  logger_1.default.debug(appDeclaration.extensions);
27
49
  if (appDeclaration.extensions) {
28
50
  for (const extension of appDeclaration?.extensions ?? []) {
29
- await this.deployExtension(extension, app, existingExtensions);
51
+ await this.deployExtension(extension, app, existingExtensions, appDeclaration.appName);
30
52
  }
31
53
  }
32
54
  }
33
- async deployExtension(extension, app, existingExtensions) {
55
+ async deployExtension(extension, app, existingExtensions, appName) {
34
56
  logger_1.default.info(`${chalk_1.default.yellow.bold('EXTENSION')} \tDeploying extension: ${extension?.identifier}`);
35
57
  const identifier = util_1.AppUtil.createExtensionIdentifier({
36
58
  appIdentifier: app.identifier,
@@ -38,28 +60,14 @@ class ExtensionsDeployer {
38
60
  });
39
61
  const existingDefinition = existingExtensions.find((ad) => ad.identifier === identifier);
40
62
  if (!existingDefinition) {
41
- await this.createNewExtensionDefinition(extension, app);
63
+ await this.createNewExtensionDefinition(extension, app, appName);
42
64
  }
43
65
  else {
44
- await this.updateActionDefinition(existingDefinition, extension, app);
66
+ await this.updateActionDefinition(existingDefinition, extension, app, appName);
45
67
  }
46
68
  }
47
- async createNewExtensionDefinition(extension, app) {
48
- const newObject = {
49
- identifier: util_1.AppUtil.createExtensionIdentifier({
50
- extensionIdentifier: extension?.identifier,
51
- appIdentifier: app.identifier,
52
- }),
53
- name: extension.name,
54
- appId: app.id,
55
- appVersionId: app.latestAppVersionId,
56
- extensionType: extension.extensionType,
57
- userApps: extension.userApps,
58
- /** @deprecated since entity-types v1.5.1, use display instead */
59
- modalDimensions: extension.modalDimensions,
60
- display: extension.display,
61
- iframeUrl: extension.iframeUrl,
62
- };
69
+ async createNewExtensionDefinition(extension, app, appName) {
70
+ const newObject = buildExtensionDefinition(extension, app, appName);
63
71
  const actionDefinition = await new sdk_1.Entities().create({
64
72
  entityName: 'app-extension-definition',
65
73
  object: newObject,
@@ -67,22 +75,8 @@ class ExtensionsDeployer {
67
75
  logger_1.default.info(`${chalk_1.default.yellow.bold('EXTENSION')} Created new extension definition: ${extension?.identifier}`);
68
76
  return actionDefinition;
69
77
  }
70
- async updateActionDefinition(existingActionDefinition, extension, app) {
71
- const updates = {
72
- identifier: util_1.AppUtil.createExtensionIdentifier({
73
- extensionIdentifier: extension?.identifier,
74
- appIdentifier: app.identifier,
75
- }),
76
- name: extension.name,
77
- appId: app.id,
78
- appVersionId: app.latestAppVersionId,
79
- extensionType: extension.extensionType,
80
- userApps: extension.userApps,
81
- /** @deprecated since entity-types v1.5.1, use display instead */
82
- modalDimensions: extension.modalDimensions,
83
- display: extension.display,
84
- iframeUrl: extension.iframeUrl,
85
- };
78
+ async updateActionDefinition(existingActionDefinition, extension, app, appName) {
79
+ const updates = buildExtensionDefinition(extension, app, appName);
86
80
  const extensionDefinition = await new sdk_1.Entities().update({
87
81
  entityName: 'app-extension-definition',
88
82
  id: existingActionDefinition.id,
@@ -0,0 +1 @@
1
+ export {};