@contrail/cli 3.5.5-alpha.ae6a66f → 3.5.5-alpha.bd66bf6
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.
- package/README.md +1 -1
- package/dist/auth/login.js +37 -15
- package/dist/auth/login.spec.js +87 -0
- package/dist/commands/feature-flags/export.js +3 -1
- package/dist/commands/feature-flags/export.spec.js +76 -1
- package/dist/commands/feature-flags/import.d.ts +14 -0
- package/dist/commands/feature-flags/import.js +48 -8
- package/dist/commands/feature-flags/import.spec.js +69 -0
- package/dist/commands/instance/export.d.ts +9 -0
- package/dist/commands/instance/export.js +18 -3
- package/dist/commands/instance/export.spec.d.ts +1 -0
- package/dist/commands/instance/export.spec.js +32 -0
- package/dist/commands/instance/import.d.ts +3 -0
- package/dist/commands/instance/import.js +5 -0
- package/dist/commands/instance/import.spec.js +4 -0
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ $ npm install -g @contrail/cli
|
|
|
17
17
|
$ contrail COMMAND
|
|
18
18
|
running command...
|
|
19
19
|
$ contrail (--version)
|
|
20
|
-
@contrail/cli/3.5.5-alpha.
|
|
20
|
+
@contrail/cli/3.5.5-alpha.bd66bf6 linux-x64 node-v24.22.1
|
|
21
21
|
$ contrail --help [COMMAND]
|
|
22
22
|
USAGE
|
|
23
23
|
$ contrail COMMAND
|
package/dist/auth/login.js
CHANGED
|
@@ -9,10 +9,10 @@ const config_1 = require("../helpers/config/config");
|
|
|
9
9
|
const logger_1 = tslib_1.__importDefault(require("../logger"));
|
|
10
10
|
const path_1 = tslib_1.__importDefault(require("path"));
|
|
11
11
|
const fs_1 = tslib_1.__importDefault(require("fs"));
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
const DEFAULT_API_GATEWAY = config_1.ENVIRONMENTS.prod;
|
|
13
|
+
const COGNITO_CONFIGS = {
|
|
14
|
+
dev: { userPoolId: 'us-east-1_e2cA0nJWu', clientId: '209s9c62o165cqab0fu5ihjtu9' },
|
|
15
|
+
prod: { userPoolId: 'us-east-1_qIaNZdUfm', clientId: '7knkt34gfacbgd0ghnuspefdfg' },
|
|
16
16
|
};
|
|
17
17
|
async function authUser() {
|
|
18
18
|
logger_1.default.debug('authorizing user...');
|
|
@@ -66,7 +66,7 @@ async function getOrgSlug(config) {
|
|
|
66
66
|
return orgSlug;
|
|
67
67
|
}
|
|
68
68
|
async function authenticateWithApiKey(config, apiKey, orgSlug) {
|
|
69
|
-
const apiGateway = config?.apiGateway ??
|
|
69
|
+
const apiGateway = config?.apiGateway ?? DEFAULT_API_GATEWAY;
|
|
70
70
|
sdk_1.Auth.config = {
|
|
71
71
|
apiUserToken: apiKey,
|
|
72
72
|
apiRefreshToken: null,
|
|
@@ -78,6 +78,10 @@ async function authenticateWithApiKey(config, apiKey, orgSlug) {
|
|
|
78
78
|
return returnAndStoreConfig(sdk_1.Auth.config);
|
|
79
79
|
}
|
|
80
80
|
async function authenticateWithUsernamePassword(config) {
|
|
81
|
+
const apiGateway = config?.apiGateway ?? DEFAULT_API_GATEWAY;
|
|
82
|
+
const cognitoConfig = getCognitoConfig(apiGateway);
|
|
83
|
+
const environment = describeEnvironment(apiGateway);
|
|
84
|
+
logger_1.default.info(`Signing in to ${environment} — use your ${environment} VibeIQ credentials.`);
|
|
81
85
|
const questions = [];
|
|
82
86
|
if (!process.env.CONTRAIL_CLI_EMAIL) {
|
|
83
87
|
questions.push({
|
|
@@ -100,14 +104,14 @@ async function authenticateWithUsernamePassword(config) {
|
|
|
100
104
|
email: process.env.CONTRAIL_CLI_EMAIL || answers.email,
|
|
101
105
|
password: process.env.CONTRAIL_CLI_PASSWORD || answers.password,
|
|
102
106
|
orgSlug: await getOrgSlug(config),
|
|
103
|
-
|
|
107
|
+
apiGateway,
|
|
104
108
|
};
|
|
105
109
|
if (!params.email || !params.password) {
|
|
106
110
|
throw new Error('Email and Password required');
|
|
107
111
|
}
|
|
108
112
|
const poolData = {
|
|
109
|
-
UserPoolId:
|
|
110
|
-
ClientId:
|
|
113
|
+
UserPoolId: cognitoConfig.userPoolId,
|
|
114
|
+
ClientId: cognitoConfig.clientId,
|
|
111
115
|
};
|
|
112
116
|
const userPool = new amazon_cognito_identity_js_1.CognitoUserPool(poolData);
|
|
113
117
|
const authData = {
|
|
@@ -123,9 +127,12 @@ async function authenticateWithUsernamePassword(config) {
|
|
|
123
127
|
return new Promise((resolve, reject) => {
|
|
124
128
|
cognitoUser.authenticateUser(authDetails, {
|
|
125
129
|
onSuccess: async (result) => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
130
|
+
try {
|
|
131
|
+
resolve(await setAuthContext(result, params));
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
reject(e);
|
|
135
|
+
}
|
|
129
136
|
},
|
|
130
137
|
onFailure: (err) => {
|
|
131
138
|
reject(err);
|
|
@@ -134,7 +141,7 @@ async function authenticateWithUsernamePassword(config) {
|
|
|
134
141
|
});
|
|
135
142
|
}
|
|
136
143
|
async function setAuthContext(user, params) {
|
|
137
|
-
const apiGateway = params
|
|
144
|
+
const apiGateway = params.apiGateway;
|
|
138
145
|
const { vibeAccessToken, vibeRefreshToken } = await getVibeAccessTokenFromCognitoAccessToken(user.idToken.jwtToken, apiGateway);
|
|
139
146
|
// Set Auth Config
|
|
140
147
|
sdk_1.Auth.config = {
|
|
@@ -171,6 +178,19 @@ async function refreshCurrentUserOrg() {
|
|
|
171
178
|
// await checkTelemetry(current_Version);
|
|
172
179
|
return returnAndStoreConfig(sdk_1.Auth.config);
|
|
173
180
|
}
|
|
181
|
+
function getCognitoConfig(apiGateway) {
|
|
182
|
+
const environment = findEnvironmentName(apiGateway);
|
|
183
|
+
if (!environment) {
|
|
184
|
+
throw new Error(`No Cognito user pool is known for ${new URL(apiGateway).host}. Email and password sign-in works for ${Object.keys(COGNITO_CONFIGS).join(' and ')}; use CONTRAIL_CLI_API_KEY for other environments.`);
|
|
185
|
+
}
|
|
186
|
+
return COGNITO_CONFIGS[environment];
|
|
187
|
+
}
|
|
188
|
+
function findEnvironmentName(apiGateway) {
|
|
189
|
+
return Object.keys(config_1.ENVIRONMENTS).find((name) => config_1.ENVIRONMENTS[name] === apiGateway);
|
|
190
|
+
}
|
|
191
|
+
function describeEnvironment(apiGateway) {
|
|
192
|
+
return findEnvironmentName(apiGateway) ?? new URL(apiGateway).host;
|
|
193
|
+
}
|
|
174
194
|
async function getVibeAccessTokenFromCognitoAccessToken(cognitoAccessToken, apiGateway) {
|
|
175
195
|
const url = `${apiGateway}/auth/cognito/`;
|
|
176
196
|
const response = await fetch(url, {
|
|
@@ -181,9 +201,11 @@ async function getVibeAccessTokenFromCognitoAccessToken(cognitoAccessToken, apiG
|
|
|
181
201
|
},
|
|
182
202
|
body: JSON.stringify({ accessToken: cognitoAccessToken }),
|
|
183
203
|
});
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
throw new Error(`${describeEnvironment(apiGateway)} rejected the sign-in (${response.status}).`);
|
|
206
|
+
}
|
|
207
|
+
const data = await response.json();
|
|
208
|
+
return { vibeAccessToken: data.accessToken, vibeRefreshToken: data.refreshToken };
|
|
187
209
|
}
|
|
188
210
|
async function requestRefreshedAccessToken(vibeRefreshToken, apiGateway) {
|
|
189
211
|
const url = `${apiGateway}/auth/jwt_refresh/`;
|
package/dist/auth/login.spec.js
CHANGED
|
@@ -1,6 +1,93 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const amazon_cognito_identity_js_1 = require("amazon-cognito-identity-js");
|
|
4
|
+
const sdk_1 = require("@contrail/sdk");
|
|
5
|
+
const config_1 = require("../helpers/config/config");
|
|
3
6
|
const login_1 = require("./login");
|
|
7
|
+
jest.mock('amazon-cognito-identity-js', () => ({
|
|
8
|
+
CognitoUserPool: jest.fn(),
|
|
9
|
+
AuthenticationDetails: jest.fn(),
|
|
10
|
+
CognitoUser: jest.fn().mockImplementation(() => ({
|
|
11
|
+
authenticateUser: (_details, callbacks) => callbacks.onSuccess({ idToken: { jwtToken: 'cognito-id-token' } }),
|
|
12
|
+
})),
|
|
13
|
+
}));
|
|
14
|
+
jest.mock('@contrail/sdk', () => ({
|
|
15
|
+
Auth: { config: {} },
|
|
16
|
+
getCurrentUser: jest.fn(),
|
|
17
|
+
setConfig: jest.fn(),
|
|
18
|
+
}));
|
|
19
|
+
jest.mock('../helpers/config/config', () => ({
|
|
20
|
+
...jest.requireActual('../helpers/config/config'),
|
|
21
|
+
getConfig: jest.fn(),
|
|
22
|
+
storeConfig: jest.fn(),
|
|
23
|
+
}));
|
|
24
|
+
jest.mock('../logger', () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
default: { debug: jest.fn(), info: jest.fn() },
|
|
27
|
+
}));
|
|
28
|
+
const DEV_GATEWAY = 'https://api.dev.vibeiq.com/dev/api';
|
|
29
|
+
function jsonResponse(status, body = {}) {
|
|
30
|
+
return new Response(JSON.stringify(body), { status });
|
|
31
|
+
}
|
|
32
|
+
describe('authUser with email and password', () => {
|
|
33
|
+
const originalFetch = global.fetch;
|
|
34
|
+
const originalEmail = process.env.CONTRAIL_CLI_EMAIL;
|
|
35
|
+
const originalPassword = process.env.CONTRAIL_CLI_PASSWORD;
|
|
36
|
+
let fetchMock;
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
jest.clearAllMocks();
|
|
39
|
+
process.env.CONTRAIL_CLI_EMAIL = 'dev@vibeiq.com';
|
|
40
|
+
process.env.CONTRAIL_CLI_PASSWORD = 'secret';
|
|
41
|
+
config_1.getConfig.mockResolvedValue({ apiGateway: DEV_GATEWAY, orgSlug: 'adam-zone-1' });
|
|
42
|
+
sdk_1.getCurrentUser.mockResolvedValue({ id: 'user-1' });
|
|
43
|
+
fetchMock = jest.fn();
|
|
44
|
+
global.fetch = fetchMock;
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
global.fetch = originalFetch;
|
|
48
|
+
process.env.CONTRAIL_CLI_EMAIL = originalEmail;
|
|
49
|
+
process.env.CONTRAIL_CLI_PASSWORD = originalPassword;
|
|
50
|
+
});
|
|
51
|
+
it('should sign in through the dev pool when the config points at dev', async () => {
|
|
52
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(200, { accessToken: 'vibe-access', refreshToken: 'vibe-refresh' }));
|
|
53
|
+
const context = await (0, login_1.authUser)();
|
|
54
|
+
expect(amazon_cognito_identity_js_1.CognitoUserPool).toHaveBeenCalledWith({
|
|
55
|
+
UserPoolId: 'us-east-1_e2cA0nJWu',
|
|
56
|
+
ClientId: '209s9c62o165cqab0fu5ihjtu9',
|
|
57
|
+
});
|
|
58
|
+
expect(context).toEqual({
|
|
59
|
+
apiUserToken: 'vibe-access',
|
|
60
|
+
apiRefreshToken: 'vibe-refresh',
|
|
61
|
+
apiGateway: DEV_GATEWAY,
|
|
62
|
+
orgSlug: 'adam-zone-1',
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
it('should sign in through the prod pool when the config points at prod', async () => {
|
|
66
|
+
config_1.getConfig.mockResolvedValue({
|
|
67
|
+
apiGateway: 'https://api.vibeiq.com/prod/api',
|
|
68
|
+
orgSlug: 'adam-zone-1',
|
|
69
|
+
});
|
|
70
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(200, { accessToken: 'vibe-access', refreshToken: 'vibe-refresh' }));
|
|
71
|
+
await (0, login_1.authUser)();
|
|
72
|
+
expect(amazon_cognito_identity_js_1.CognitoUserPool).toHaveBeenCalledWith({
|
|
73
|
+
UserPoolId: 'us-east-1_qIaNZdUfm',
|
|
74
|
+
ClientId: '7knkt34gfacbgd0ghnuspefdfg',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
it('should fail without saving tokens when no pool is known for the environment', async () => {
|
|
78
|
+
config_1.getConfig.mockResolvedValue({
|
|
79
|
+
apiGateway: 'http://localhost:3000/api',
|
|
80
|
+
orgSlug: 'adam-zone-1',
|
|
81
|
+
});
|
|
82
|
+
await expect((0, login_1.authUser)()).rejects.toThrow('No Cognito user pool is known for localhost:3000.');
|
|
83
|
+
expect(config_1.storeConfig).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
it('should fail without saving tokens when the environment rejects the sign-in', async () => {
|
|
86
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(401, { message: 'Unauthorized' }));
|
|
87
|
+
await expect((0, login_1.authUser)()).rejects.toThrow('dev rejected the sign-in (401).');
|
|
88
|
+
expect(config_1.storeConfig).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
4
91
|
describe('getVersionFromPackageJson', () => {
|
|
5
92
|
it('should return the version value from package.json', () => {
|
|
6
93
|
const version = (0, login_1.getVersionFromPackageJson)();
|
|
@@ -8,6 +8,7 @@ const fs_1 = tslib_1.__importDefault(require("fs"));
|
|
|
8
8
|
const login_1 = require("../../auth/login");
|
|
9
9
|
const core_1 = require("@oclif/core");
|
|
10
10
|
const file_helpers_1 = require("../../helpers/file-helpers");
|
|
11
|
+
const error_helpers_1 = require("../../helpers/error-helpers");
|
|
11
12
|
const feature_flags_1 = require("../../api/feature-flags");
|
|
12
13
|
const basecommand_1 = require("../../basecommand");
|
|
13
14
|
exports.FEATURE_FLAGS_FILE = 'feature-flags.yml';
|
|
@@ -41,7 +42,8 @@ class ExportFeatureFlags extends basecommand_1.BaseCommand {
|
|
|
41
42
|
this.log(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} Exported`);
|
|
42
43
|
}
|
|
43
44
|
catch (e) {
|
|
44
|
-
this.log(`${chalk_1.default.red.bold('FAIL')} Feature Flags Export Failed. ${e}`);
|
|
45
|
+
this.log(`${chalk_1.default.red.bold('FAIL')} Feature Flags Export Failed. ${(0, error_helpers_1.describeError)(e)}`);
|
|
46
|
+
process.exitCode = 1;
|
|
45
47
|
return;
|
|
46
48
|
}
|
|
47
49
|
this.log(`${chalk_1.default.green.bold('COMPLETE')} Feature Flags Export Complete.`);
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const tslib_1 = require("tslib");
|
|
4
4
|
const fs_1 = tslib_1.__importDefault(require("fs"));
|
|
5
|
-
const export_1 = require("./export");
|
|
5
|
+
const export_1 = tslib_1.__importStar(require("./export"));
|
|
6
6
|
const file_helpers_1 = require("../../helpers/file-helpers");
|
|
7
7
|
const feature_flags_1 = require("../../api/feature-flags");
|
|
8
|
+
const login_1 = require("../../auth/login");
|
|
8
9
|
jest.mock('../../helpers/file-helpers');
|
|
9
10
|
jest.mock('../../api/feature-flags');
|
|
11
|
+
jest.mock('../../auth/login');
|
|
10
12
|
const mocked = (fn) => fn;
|
|
11
13
|
describe('exportFeatureFlags', () => {
|
|
12
14
|
let mkdirSyncSpy;
|
|
@@ -32,3 +34,76 @@ describe('exportFeatureFlags', () => {
|
|
|
32
34
|
expect(mocked(file_helpers_1.saveData)).toHaveBeenCalledWith([], 'out-dir/feature-flags.yml', false);
|
|
33
35
|
});
|
|
34
36
|
});
|
|
37
|
+
describe('ExportFeatureFlags command', () => {
|
|
38
|
+
let mkdirSyncSpy;
|
|
39
|
+
let logs;
|
|
40
|
+
// oclif's init() parses argv and reads the on-disk config, so drive run() with flags set directly
|
|
41
|
+
const runCommand = async () => {
|
|
42
|
+
const command = new export_1.default([], {});
|
|
43
|
+
command.flags = { outDir: 'out-dir', overwrite: true };
|
|
44
|
+
jest.spyOn(command, 'log').mockImplementation((message) => logs.push(String(message)));
|
|
45
|
+
await command.run();
|
|
46
|
+
};
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
jest.clearAllMocks();
|
|
49
|
+
logs = [];
|
|
50
|
+
process.exitCode = undefined;
|
|
51
|
+
mkdirSyncSpy = jest.spyOn(fs_1.default, 'mkdirSync').mockReturnValue(undefined);
|
|
52
|
+
mocked(login_1.authUser).mockResolvedValue({});
|
|
53
|
+
mocked(file_helpers_1.saveData).mockResolvedValue(undefined);
|
|
54
|
+
mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([]);
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
mkdirSyncSpy.mockRestore();
|
|
58
|
+
process.exitCode = undefined;
|
|
59
|
+
});
|
|
60
|
+
it('leaves the exit code alone on success', async () => {
|
|
61
|
+
await runCommand();
|
|
62
|
+
expect(process.exitCode).toBeUndefined();
|
|
63
|
+
expect(logs.join('\n')).toContain('Feature Flags Export Complete');
|
|
64
|
+
});
|
|
65
|
+
const failures = [
|
|
66
|
+
[
|
|
67
|
+
'authentication fails',
|
|
68
|
+
() => {
|
|
69
|
+
mocked(login_1.authUser).mockRejectedValue(new Error('boom'));
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
'the API read fails',
|
|
74
|
+
() => {
|
|
75
|
+
mocked(feature_flags_1.getFeatureFlags).mockRejectedValue(new Error('boom'));
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
'the directory cannot be created',
|
|
80
|
+
() => {
|
|
81
|
+
mkdirSyncSpy.mockImplementation(() => {
|
|
82
|
+
throw new Error('boom');
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
'the file write fails',
|
|
88
|
+
() => {
|
|
89
|
+
mocked(file_helpers_1.saveData).mockRejectedValue(new Error('boom'));
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
];
|
|
93
|
+
it.each(failures)('exits non-zero when %s, so automation does not read it as a success', async (_, fail) => {
|
|
94
|
+
fail();
|
|
95
|
+
await runCommand();
|
|
96
|
+
expect(process.exitCode).toBe(1);
|
|
97
|
+
const output = logs.join('\n');
|
|
98
|
+
expect(output).toContain('Feature Flags Export Failed');
|
|
99
|
+
expect(output).not.toContain('Feature Flags Export Complete');
|
|
100
|
+
});
|
|
101
|
+
it('reports the API message rather than a bare "Error"', async () => {
|
|
102
|
+
// the SDK's ApiError has an undefined message and keeps the body in details
|
|
103
|
+
mocked(feature_flags_1.getFeatureFlags).mockRejectedValue({
|
|
104
|
+
details: '{"message":"Forbidden resource","statusCode":403}',
|
|
105
|
+
});
|
|
106
|
+
await runCommand();
|
|
107
|
+
expect(logs.join('\n')).toContain('Forbidden resource');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -16,6 +16,16 @@ export default class ImportFeatureFlags extends BaseCommand {
|
|
|
16
16
|
* the command before it half-applies the file.
|
|
17
17
|
*/
|
|
18
18
|
export declare function assertCanManageFeatureFlags(): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* The `instance import` variant: feature flags are one step of a much larger config copy that
|
|
21
|
+
* org admins without a platform role are expected to run, so a missing role (or roles we could
|
|
22
|
+
* not read) logs and skips instead of failing the whole import. Errors from the flag upload
|
|
23
|
+
* itself still propagate — only the permission check is soft here.
|
|
24
|
+
*
|
|
25
|
+
* `contrail feature-flags import` deliberately does the opposite and fails, because there the
|
|
26
|
+
* flags are the entire point of the command.
|
|
27
|
+
*/
|
|
28
|
+
export declare function uploadFeatureFlagsIfPermitted(dir: string): Promise<void>;
|
|
19
29
|
/**
|
|
20
30
|
* Makes the target org's enabled feature flags match $dir/feature-flags.yml: enables every
|
|
21
31
|
* flag in the file and disables any flag the target has that the file does not, the same
|
|
@@ -24,5 +34,9 @@ export declare function assertCanManageFeatureFlags(): Promise<void>;
|
|
|
24
34
|
*
|
|
25
35
|
* Failures are collected and rethrown so an import never reports success while the org is
|
|
26
36
|
* missing flags.
|
|
37
|
+
*
|
|
38
|
+
* A file that lists no flags at all is skipped rather than applied: reconciling against an
|
|
39
|
+
* empty list would disable every flag in the target org, which is almost never what a copy
|
|
40
|
+
* is meant to do. Flags that should be off in the target have to be disabled by hand.
|
|
27
41
|
*/
|
|
28
42
|
export declare function uploadFeatureFlags(dir: string): Promise<void>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.uploadFeatureFlags = exports.assertCanManageFeatureFlags = exports.REQUIRED_PLATFORM_ROLES = void 0;
|
|
3
|
+
exports.uploadFeatureFlags = exports.uploadFeatureFlagsIfPermitted = exports.assertCanManageFeatureFlags = exports.REQUIRED_PLATFORM_ROLES = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const path = tslib_1.__importStar(require("path"));
|
|
6
6
|
const chalk_1 = tslib_1.__importDefault(require("chalk"));
|
|
@@ -48,26 +48,57 @@ class ImportFeatureFlags extends basecommand_1.BaseCommand {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
exports.default = ImportFeatureFlags;
|
|
51
|
+
async function getPlatformRoles() {
|
|
52
|
+
try {
|
|
53
|
+
const currentUser = await (0, users_1.getCurrentUserWithPlatformRoles)();
|
|
54
|
+
return normalizePlatformRoles(currentUser?.platformRoles);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
throw new Error(`Could not read your platform roles. ${(0, error_helpers_1.describeError)(e)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
51
60
|
/**
|
|
52
61
|
* The feature flags API only lets a platform admin/manager create or delete flags, and a
|
|
53
62
|
* missing role surfaces as a generic 403 partway through the run. Checking up front stops
|
|
54
63
|
* the command before it half-applies the file.
|
|
55
64
|
*/
|
|
56
65
|
async function assertCanManageFeatureFlags() {
|
|
66
|
+
const platformRoles = await getPlatformRoles();
|
|
67
|
+
if (!platformRoles.some((role) => exports.REQUIRED_PLATFORM_ROLES.includes(role))) {
|
|
68
|
+
throw new Error(`Importing feature flags requires the ${exports.REQUIRED_PLATFORM_ROLES.join(' or ')} platform role. ` +
|
|
69
|
+
`Your roles: ${platformRoles.length ? platformRoles.join(', ') : 'none'}.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.assertCanManageFeatureFlags = assertCanManageFeatureFlags;
|
|
73
|
+
/**
|
|
74
|
+
* The `instance import` variant: feature flags are one step of a much larger config copy that
|
|
75
|
+
* org admins without a platform role are expected to run, so a missing role (or roles we could
|
|
76
|
+
* not read) logs and skips instead of failing the whole import. Errors from the flag upload
|
|
77
|
+
* itself still propagate — only the permission check is soft here.
|
|
78
|
+
*
|
|
79
|
+
* `contrail feature-flags import` deliberately does the opposite and fails, because there the
|
|
80
|
+
* flags are the entire point of the command.
|
|
81
|
+
*/
|
|
82
|
+
async function uploadFeatureFlagsIfPermitted(dir) {
|
|
57
83
|
let platformRoles;
|
|
58
84
|
try {
|
|
59
|
-
|
|
60
|
-
platformRoles = normalizePlatformRoles(currentUser?.platformRoles);
|
|
85
|
+
platformRoles = await getPlatformRoles();
|
|
61
86
|
}
|
|
62
87
|
catch (e) {
|
|
63
|
-
|
|
88
|
+
logger_1.default.info(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} Skipping feature flags: ${(0, error_helpers_1.describeError)(e)} ` +
|
|
89
|
+
`Ask a platform admin to run 'contrail feature-flags import -d ${dir}'.`);
|
|
90
|
+
return;
|
|
64
91
|
}
|
|
65
92
|
if (!platformRoles.some((role) => exports.REQUIRED_PLATFORM_ROLES.includes(role))) {
|
|
66
|
-
|
|
67
|
-
|
|
93
|
+
logger_1.default.info(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} Skipping feature flags: this requires the ` +
|
|
94
|
+
`${exports.REQUIRED_PLATFORM_ROLES.join(' or ')} platform role and you have ` +
|
|
95
|
+
`${platformRoles.length ? platformRoles.join(', ') : 'none'}. ` +
|
|
96
|
+
`Ask a platform admin to run 'contrail feature-flags import -d ${dir}'.`);
|
|
97
|
+
return;
|
|
68
98
|
}
|
|
99
|
+
await uploadFeatureFlags(dir);
|
|
69
100
|
}
|
|
70
|
-
exports.
|
|
101
|
+
exports.uploadFeatureFlagsIfPermitted = uploadFeatureFlagsIfPermitted;
|
|
71
102
|
/**
|
|
72
103
|
* Makes the target org's enabled feature flags match $dir/feature-flags.yml: enables every
|
|
73
104
|
* flag in the file and disables any flag the target has that the file does not, the same
|
|
@@ -76,6 +107,10 @@ exports.assertCanManageFeatureFlags = assertCanManageFeatureFlags;
|
|
|
76
107
|
*
|
|
77
108
|
* Failures are collected and rethrown so an import never reports success while the org is
|
|
78
109
|
* missing flags.
|
|
110
|
+
*
|
|
111
|
+
* A file that lists no flags at all is skipped rather than applied: reconciling against an
|
|
112
|
+
* empty list would disable every flag in the target org, which is almost never what a copy
|
|
113
|
+
* is meant to do. Flags that should be off in the target have to be disabled by hand.
|
|
79
114
|
*/
|
|
80
115
|
async function uploadFeatureFlags(dir) {
|
|
81
116
|
const filePath = path.join(dir, export_1.FEATURE_FLAGS_FILE);
|
|
@@ -83,11 +118,16 @@ async function uploadFeatureFlags(dir) {
|
|
|
83
118
|
logger_1.default.info(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} No feature flags found in ${dir}. Skipping.`);
|
|
84
119
|
return;
|
|
85
120
|
}
|
|
86
|
-
const data = (0, file_helpers_1.loadData)(filePath);
|
|
121
|
+
const data = (0, file_helpers_1.loadData)(filePath) ?? [];
|
|
87
122
|
if (!Array.isArray(data)) {
|
|
88
123
|
throw new Error(`${filePath} must contain a YAML list of feature flags`);
|
|
89
124
|
}
|
|
90
125
|
const featureNamesToEnable = new Set(data.map(toFeatureName).filter(Boolean));
|
|
126
|
+
if (featureNamesToEnable.size === 0) {
|
|
127
|
+
logger_1.default.info(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} ${filePath} lists no feature flags. Skipping, so ` +
|
|
128
|
+
'the flags currently enabled in this org are left as they are.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
91
131
|
const existingFlags = (await (0, feature_flags_1.getFeatureFlags)()) ?? [];
|
|
92
132
|
const enabledFeatureNames = new Set(existingFlags.map((flag) => flag?.featureName));
|
|
93
133
|
const toCreate = [...featureNamesToEnable].filter((name) => !enabledFeatureNames.has(name));
|
|
@@ -58,6 +58,26 @@ describe('uploadFeatureFlags', () => {
|
|
|
58
58
|
expect(mocked(feature_flags_1.getFeatureFlags)).not.toHaveBeenCalled();
|
|
59
59
|
expect(mocked(feature_flags_1.createFeatureFlag)).not.toHaveBeenCalled();
|
|
60
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
|
+
});
|
|
61
81
|
it('enables every flag from the export that the target org is missing', async () => {
|
|
62
82
|
mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }, { featureName: 'PROMOTE' }]);
|
|
63
83
|
mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([{ id: '1', featureName: 'SHOWROOM' }]);
|
|
@@ -123,3 +143,52 @@ describe('uploadFeatureFlags', () => {
|
|
|
123
143
|
expect(mocked(feature_flags_1.createFeatureFlag)).toHaveBeenCalledWith('PROMOTE');
|
|
124
144
|
});
|
|
125
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 (
|
|
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++) {
|
package/oclif.manifest.json
CHANGED